diff --git a/studio/backend/main.py b/studio/backend/main.py index 54946fa992..017e78fb35 100644 --- a/studio/backend/main.py +++ b/studio/backend/main.py @@ -379,24 +379,18 @@ def _start_helper_precache_if_enabled() -> None: threading.Thread(target = _precache, daemon = True, name = "helper-gguf-precache").start() -@asynccontextmanager -async def lifespan(app: FastAPI): - """Startup: detect hardware, seed default admin if needed. Shutdown: clean up compiled cache.""" - clear_unsloth_compiled_cache() +def _run_llama_cpp_startup_probes(app: FastAPI) -> None: + """llama.cpp capability (MTP support) + freshness (release age) probes. - # Remove stale .venv_overlay from old versions; switching now uses .venv_t5/. - overlay_dir = Path(__file__).resolve().parent.parent.parent / ".venv_overlay" - if overlay_dir.is_dir(): - shutil.rmtree(overlay_dir, ignore_errors = True) - - # Detect hardware first — sets the DEVICE global used everywhere. - detect_hardware() - - # Reap download workers orphaned by a previous crash before new downloads start. - reap_hub_orphan_workers() - - # llama.cpp probes: capability (MTP support) + freshness (release age). - # Both cached; freshness has a 24h disk TTL. + Runs OFF the startup critical path (see _start_llama_cpp_probes_if_enabled). + Both are cached and freshness has a 24h disk TTL, but on a cold/expired cache + the freshness check makes a blocking GitHub request, and on macOS the first + `llama-server --help` exec can stall on Gatekeeper verification -- neither must + ever gate `Application startup complete`. Writes app.state only; nothing reads + those values synchronously at startup (the status routes call + check_prebuilt_freshness directly at request time), so populating them late is + safe. + """ try: from core.inference.llama_cpp import LlamaCppBackend from utils.llama_cpp_freshness import ( @@ -429,6 +423,61 @@ async def lifespan(app: FastAPI): import structlog as _structlog _structlog.get_logger(__name__).debug("llama.cpp startup probes failed: %s", _probe_exc) + +def _start_llama_cpp_probes_if_enabled(app: FastAPI) -> None: + """Run the llama.cpp startup probes on a daemon thread, off the startup + critical path so they never delay `Application startup complete`. Skipped + entirely when update checks are disabled, so a fully offline boot makes no + background network calls.""" + if os.environ.get("UNSLOTH_DISABLE_UPDATE_CHECK") == "1": + return + + threading.Thread( + target = _run_llama_cpp_startup_probes, + args = (app,), + daemon = True, + name = "llama-cpp-startup-probe", + ).start() + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Startup: detect hardware, seed default admin if needed. Shutdown: clean up compiled cache.""" + clear_unsloth_compiled_cache() + + # Remove stale .venv_overlay from old versions; switching now uses .venv_t5/. + overlay_dir = Path(__file__).resolve().parent.parent.parent / ".venv_overlay" + if overlay_dir.is_dir(): + shutil.rmtree(overlay_dir, ignore_errors = True) + + # Detect hardware first — sets the DEVICE global used everywhere. + detect_hardware() + + # Apple Silicon with MLX missing => Train/Export are greyed out (chat-only). + # Reinstall mlx by name on a background thread (off the critical path) and + # re-detect, so a reinstall/update that dropped mlx self-heals. No-op + # elsewhere; opt out with UNSLOTH_DISABLE_MLX_AUTOREPAIR=1. + try: + from utils.mlx_repair import start_mlx_autorepair_if_needed + start_mlx_autorepair_if_needed() + except Exception as _mlx_exc: + import structlog as _structlog + _structlog.get_logger(__name__).debug("mlx autorepair skipped: %s", _mlx_exc) + + # Reap download workers orphaned by a previous crash before new downloads start. + reap_hub_orphan_workers() + + # llama.cpp probes: capability (MTP support) + freshness (release age). + # These used to run inline here and could block `Application startup complete` + # for tens of seconds on macOS (cold GitHub freshness cache / slow network, and + # Gatekeeper verifying the unsigned binary on first `--help` exec). They only + # write app.state and nothing reads it synchronously at startup, so run them on + # a daemon thread off the startup critical path (mirrors the helper-precache and + # RAG-warm threads). Default to None until the thread populates them. + app.state.llama_cpp_capabilities = None + app.state.llama_cpp_freshness = None + _start_llama_cpp_probes_if_enabled(app) + from storage.studio_db import cleanup_orphaned_runs try: @@ -909,6 +958,8 @@ async def health_check(request: Request): device_type = platform_map.get(sys.platform, sys.platform) return { **base, + # Why chat_only is set. This fingerprints the host, so keep it authed. + "chat_only_reason": getattr(_hw_module, "CHAT_ONLY_REASON", None), "version": UNSLOTH_VERSION, "studio_version": STUDIO_VERSION, "device_type": device_type, diff --git a/studio/backend/tests/test_chat_only_reason.py b/studio/backend/tests/test_chat_only_reason.py new file mode 100644 index 0000000000..405bb2d28f --- /dev/null +++ b/studio/backend/tests/test_chat_only_reason.py @@ -0,0 +1,83 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""detect_hardware() records WHY a host is chat-only so the UI can explain the +greyed-out Train/Export instead of disabling them silently. + +The key case is Apple Silicon without an importable MLX -> "mlx_unavailable", +which is the usual cause of "Train and Export greyed out" on Macs after a +reinstall/update dropped MLX. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +_BACKEND = Path(__file__).resolve().parent.parent +if str(_BACKEND) not in sys.path: + sys.path.insert(0, str(_BACKEND)) + +import utils.hardware.hardware as hw # noqa: E402 + + +@pytest.fixture(autouse = True) +def _no_torch(monkeypatch): + # Force the non-CUDA/XPU path regardless of the test host's real GPUs. + monkeypatch.setattr(hw, "_has_torch", lambda: False) + # detect_hardware() assigns these module globals directly (not via monkeypatch), + # so save and restore them; otherwise a chat-only verdict here leaks into other + # backend tests (e.g. test_utils.py) when they share a process on a GPU host. + saved = (hw.DEVICE, hw.CHAT_ONLY, hw.CHAT_ONLY_REASON, hw.IS_ROCM) + try: + yield + finally: + hw.DEVICE, hw.CHAT_ONLY, hw.CHAT_ONLY_REASON, hw.IS_ROCM = saved + + +def test_apple_silicon_without_mlx_is_chat_only_with_reason(monkeypatch): + monkeypatch.setattr(hw, "is_apple_silicon", lambda: True) + monkeypatch.setattr(hw, "_has_usable_mlx_stack", lambda: False) + hw.detect_hardware() + assert hw.CHAT_ONLY is True + assert hw.CHAT_ONLY_REASON == "mlx_unavailable" + + +def test_apple_silicon_with_mlx_enables_training(monkeypatch): + monkeypatch.setattr(hw, "is_apple_silicon", lambda: True) + monkeypatch.setattr(hw, "_has_usable_mlx_stack", lambda: True) + hw.detect_hardware() + assert hw.CHAT_ONLY is False + assert hw.CHAT_ONLY_REASON is None + + +def test_apple_silicon_with_incomplete_mlx_stack_stays_chat_only(monkeypatch): + # Bare `import mlx.core` works but the full mlx/mlx-lm/mlx-vlm stack does not + # (e.g. a backtracked/old mlx-vlm). The training gate must match the self-heal + # validator and stay chat-only so the UI does not enable a broken Train/Export. + monkeypatch.setattr(hw, "is_apple_silicon", lambda: True) + monkeypatch.setattr(hw, "_has_mlx", lambda: True) + monkeypatch.setattr(hw, "_has_usable_mlx_stack", lambda: False) + assert hw.detect_hardware() == hw.DeviceType.CPU + assert hw.CHAT_ONLY is True + assert hw.CHAT_ONLY_REASON == "mlx_unavailable" + + +def test_intel_mac_reason(monkeypatch): + monkeypatch.setattr(hw, "is_apple_silicon", lambda: False) + monkeypatch.setattr(hw, "_has_mlx", lambda: False) + monkeypatch.setattr(hw.platform, "system", lambda: "Darwin") + hw.detect_hardware() + assert hw.CHAT_ONLY is True + assert hw.CHAT_ONLY_REASON == "intel_mac" + + +def test_cpu_only_non_mac_reason(monkeypatch): + monkeypatch.setattr(hw, "is_apple_silicon", lambda: False) + monkeypatch.setattr(hw, "_has_mlx", lambda: False) + monkeypatch.setattr(hw.platform, "system", lambda: "Linux") + hw.detect_hardware() + assert hw.CHAT_ONLY is True + assert hw.CHAT_ONLY_REASON == "no_gpu" diff --git a/studio/backend/tests/test_desktop_auth.py b/studio/backend/tests/test_desktop_auth.py index b4b39ba1a9..591d44b736 100644 --- a/studio/backend/tests/test_desktop_auth.py +++ b/studio/backend/tests/test_desktop_auth.py @@ -451,7 +451,8 @@ def test_health_response_reports_desktop_capability_fields(monkeypatch): import studio.backend.main as backend_main - monkeypatch.setattr(backend_main._hw_module, "CHAT_ONLY", False) + monkeypatch.setattr(backend_main._hw_module, "CHAT_ONLY", True) + monkeypatch.setattr(backend_main._hw_module, "CHAT_ONLY_REASON", "mlx_unavailable") seed_user() from auth.authentication import create_access_token @@ -462,6 +463,12 @@ def test_health_response_reports_desktop_capability_fields(monkeypatch): app.add_api_route("/api/health", backend_main.health_check, methods = ["GET"]) client = TestClient(app) + unauthenticated = client.get("/api/health") + assert unauthenticated.status_code == 200 + unauthenticated_body = unauthenticated.json() + assert unauthenticated_body["chat_only"] is True + assert "chat_only_reason" not in unauthenticated_body + response = client.get( "/api/health", headers = {"Authorization": f"Bearer {token}"}, @@ -471,6 +478,7 @@ def test_health_response_reports_desktop_capability_fields(monkeypatch): assert body["desktop_protocol_version"] == 1 assert body["supports_desktop_auth"] is True + assert body["chat_only_reason"] == "mlx_unavailable" def test_provision_desktop_auth_writes_secret_and_creates_db_without_backend_deps( diff --git a/studio/backend/tests/test_install_resolve_prebuilt.py b/studio/backend/tests/test_install_resolve_prebuilt.py index ede5629664..d69eccc54d 100644 --- a/studio/backend/tests/test_install_resolve_prebuilt.py +++ b/studio/backend/tests/test_install_resolve_prebuilt.py @@ -105,6 +105,44 @@ def test_published_repo_for_host(): ) +def test_macos_intel_and_arm_both_route_to_fork(): + # macOS uses the unslothai fork's own Mac prebuilts for BOTH arm64 and Intel; + # there is no longer any upstream-on-macOS default path, so the obsolete + # pre-macOS-26 pin (b9415) is gone. + assert ( + ilp.published_repo_for_host( + _host(system = "Darwin", is_macos = True, is_arm64 = True, machine = "arm64") + ) + == FORK + ) + assert ( + ilp.published_repo_for_host( + _host(system = "Darwin", is_macos = True, is_x86_64 = True, machine = "x86_64") + ) + == FORK + ) + + +def test_macos_upstream_pin_only_for_explicit_pre26_upstream(): + pre26 = _host( + system = "Darwin", + is_macos = True, + is_arm64 = True, + machine = "arm64", + macos_version = (15, 5), + ) + assert ilp.pinned_macos_release_tag(pre26, UPSTREAM) == "b9415" + assert ilp.pinned_macos_release_tag(pre26, FORK) is None + tahoe = _host( + system = "Darwin", + is_macos = True, + is_arm64 = True, + machine = "arm64", + macos_version = (26, 0), + ) + assert ilp.pinned_macos_release_tag(tahoe, UPSTREAM) is None + + def _run_resolve(monkeypatch, capsys, plans_or_exc): monkeypatch.setattr( ilp, diff --git a/studio/backend/tests/test_mlx_repair.py b/studio/backend/tests/test_mlx_repair.py new file mode 100644 index 0000000000..7e60c9c3b9 --- /dev/null +++ b/studio/backend/tests/test_mlx_repair.py @@ -0,0 +1,278 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""MLX self-heal: on Apple Silicon with MLX missing, reinstall it by name on a +background thread (off the startup critical path). No-op elsewhere / when present +/ when disabled. Models on core.training.worker's runtime backend self-heal. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +_BACKEND = Path(__file__).resolve().parent.parent +if str(_BACKEND) not in sys.path: + sys.path.insert(0, str(_BACKEND)) + +import utils.mlx_repair as mr # noqa: E402 + + +@pytest.fixture(autouse = True) +def _reset_attempt_guard(monkeypatch): + monkeypatch.setattr(mr, "_attempted", False) + monkeypatch.delenv(mr.DISABLE_ENV_VAR, raising = False) + + +def test_uv_cmd_targets_this_interpreter_with_mlx_packages(monkeypatch): + monkeypatch.setattr(mr, "_uv_executable", lambda: "/usr/bin/uv") + cmd = mr._uv_install_cmd("--upgrade", *mr.MLX_PACKAGES) + assert cmd is not None + assert cmd[:5] == ["/usr/bin/uv", "pip", "install", "--python", sys.executable] + assert set(mr.MLX_PACKAGES) <= set(cmd) + # Minimum versions are pinned so the resolver cannot backtrack to an old + # mlx-vlm that imports but breaks VLM Train/Export. + assert "mlx-vlm>=0.4.4" in cmd + + +def test_uv_executable_finds_installer_location_when_path_is_minimal(monkeypatch, tmp_path): + uv = tmp_path / ".local" / "bin" / "uv" + uv.parent.mkdir(parents = True) + uv.write_text("#!/bin/sh\n", encoding = "utf-8") + uv.chmod(0o755) + monkeypatch.setattr(mr.shutil, "which", lambda _x: None) + monkeypatch.setattr(mr.Path, "home", lambda: tmp_path) + assert mr._uv_executable() == str(uv) + + +def test_no_uv_repair_stays_chat_only_without_pip(monkeypatch): + monkeypatch.setattr(mr, "_uv_executable", lambda: None) + monkeypatch.setattr(mr, "_transformers_constraint_args", lambda: ([], None)) + called = {"run": False} + + def _fake_run(*_args, **_kwargs): + called["run"] = True + raise AssertionError("plain pip fallback must not run") + + monkeypatch.setattr(mr.subprocess, "run", _fake_run) + assert mr.attempt_mlx_repair() is False + assert called["run"] is False + + +def test_constraint_pins_installed_transformers(monkeypatch): + transformers = pytest.importorskip("transformers") + args, path = mr._transformers_constraint_args() + try: + assert args[:1] == ["--constraint"] + assert args[1] == path + assert Path(path).read_text().strip() == f"transformers=={transformers.__version__}" + finally: + if path: + Path(path).unlink(missing_ok = True) + + +def test_repair_install_pins_transformers_and_cleans_up(monkeypatch): + pytest.importorskip("transformers") + captured = {} + created_paths = [] + real_args = mr._transformers_constraint_args + + def _spy_args(): + args, path = real_args() + if path: + created_paths.append(path) + return args, path + + monkeypatch.setattr(mr, "_transformers_constraint_args", _spy_args) + monkeypatch.setattr(mr, "_uv_executable", lambda: "/usr/bin/uv") + + class _Result: + returncode = 0 + stdout = "" + + def _fake_run(cmd, **kwargs): + captured["cmd"] = cmd + captured["env"] = kwargs.get("env") + return _Result() + + monkeypatch.setattr(mr.subprocess, "run", _fake_run) + monkeypatch.setattr(mr, "mlx_stack_available", lambda: True) + + assert mr.attempt_mlx_repair() is True + cmd = captured["cmd"] + # transformers is pinned via a constraint file so the mlx install cannot + # upgrade it underneath Studio, and the temp constraint file is cleaned up. + assert "--constraint" in cmd + assert "--upgrade" in cmd + reinstall_pairs = set(zip(cmd, cmd[1:])) + for name in mr._MLX_PACKAGE_NAMES: + assert ("--reinstall-package", name) in reinstall_pairs + for pkg in mr.MLX_PACKAGES: + assert pkg in cmd + assert created_paths and not Path(created_paths[0]).exists() + # The install mirrors the main installer by relaxing the transformers pin via + # UV_OVERRIDE so a current mlx-vlm can coexist with transformers==4.57.6. + env = captured["env"] + assert env is not None + assert env.get("UV_OVERRIDE", "").endswith("overrides-darwin-arm64.txt") + + +def test_repair_rejects_inadequate_stack(monkeypatch): + # A successful uv run that still leaves an old/missing mlx-vlm must NOT clear + # chat-only: attempt_mlx_repair returns False so Train/Export stay disabled. + class _Result: + returncode = 0 + stdout = "" + + monkeypatch.setattr(mr.subprocess, "run", lambda *a, **k: _Result()) + monkeypatch.setattr(mr, "mlx_stack_available", lambda: False) + assert mr.attempt_mlx_repair() is False + + +def test_repair_invalidates_import_caches_before_stack_check(monkeypatch): + events = [] + + class _Result: + returncode = 0 + stdout = "" + + def _stack_available(): + events.append("check") + assert events == ["invalidate", "check"] + return True + + monkeypatch.setattr(mr.subprocess, "run", lambda *a, **k: _Result()) + monkeypatch.setattr(mr, "_uv_executable", lambda: "/usr/bin/uv") + monkeypatch.setattr(mr, "_transformers_constraint_args", lambda: ([], None)) + monkeypatch.setattr(mr.importlib, "invalidate_caches", lambda: events.append("invalidate")) + monkeypatch.setattr(mr, "mlx_stack_available", _stack_available) + + assert mr.attempt_mlx_repair() is True + assert events == ["invalidate", "check"] + + +def test_stack_unavailable_without_mlx(monkeypatch): + import importlib.metadata as metadata + + def _missing(_name): + raise metadata.PackageNotFoundError(_name) + + monkeypatch.setattr(metadata, "version", _missing) + assert mr.mlx_stack_available() is False + + +def test_stack_unavailable_checks_versions_before_imports(monkeypatch): + import importlib.metadata as metadata + + def _version(name): + if name == "mlx": + return "0.21.0" + return mr._MLX_MIN_VERSIONS[name] + + def _import_module(_name): + raise AssertionError("MLX modules must not import before versions pass") + + monkeypatch.setattr(metadata, "version", _version) + monkeypatch.setattr(mr.importlib, "import_module", _import_module) + assert mr.mlx_stack_available() is False + + +def test_stack_unavailable_when_companion_import_fails(monkeypatch): + import importlib.metadata as metadata + + monkeypatch.setattr(metadata, "version", lambda name: mr._MLX_MIN_VERSIONS[name]) + + def _import_module(name): + if name == "mlx_vlm": + raise ModuleNotFoundError(name) + return object() + + monkeypatch.setattr(mr.importlib, "import_module", _import_module) + assert mr.mlx_stack_available() is False + + +def test_stack_available_requires_runtime_imports_and_versions(monkeypatch): + import importlib.metadata as metadata + + imported = [] + + def _import_module(name): + imported.append(name) + return object() + + monkeypatch.setattr(mr.importlib, "import_module", _import_module) + monkeypatch.setattr(metadata, "version", lambda name: mr._MLX_MIN_VERSIONS[name]) + + assert mr.mlx_stack_available() is True + assert imported == list(mr._MLX_RUNTIME_IMPORTS) + + +def test_no_op_off_apple_silicon(monkeypatch): + monkeypatch.setattr(mr, "is_apple_silicon", lambda: False) + called = {"n": 0} + monkeypatch.setattr( + mr, "attempt_mlx_repair", lambda **_k: called.__setitem__("n", called["n"] + 1) or True + ) + assert mr.start_mlx_autorepair_if_needed() is False + assert called["n"] == 0 + + +def test_no_op_when_mlx_stack_present(monkeypatch): + monkeypatch.setattr(mr, "is_apple_silicon", lambda: True) + monkeypatch.setattr(mr, "mlx_stack_available", lambda: True) + started = mr.start_mlx_autorepair_if_needed() + assert started is False + + +def test_disable_env_skips(monkeypatch): + monkeypatch.setattr(mr, "is_apple_silicon", lambda: True) + monkeypatch.setattr(mr, "mlx_stack_available", lambda: False) + monkeypatch.setenv(mr.DISABLE_ENV_VAR, "1") + assert mr.start_mlx_autorepair_if_needed() is False + + +def test_apple_silicon_missing_mlx_starts_repair_and_redetects(monkeypatch): + import threading + + monkeypatch.setattr(mr, "is_apple_silicon", lambda: True) + monkeypatch.setattr(mr, "mlx_stack_available", lambda: False) + + repaired = {"called": False} + + def _fake_repair(**_kw): + repaired["called"] = True + return True + + redetected = {"called": False} + + # _run_repair_and_redetect imports utils.hardware.hardware lazily; stub repair + # and capture that re-detection is invoked on success. + monkeypatch.setattr(mr, "attempt_mlx_repair", _fake_repair) + + import utils.hardware.hardware as hw + + monkeypatch.setattr(hw, "detect_hardware", lambda: redetected.__setitem__("called", True)) + + started = mr.start_mlx_autorepair_if_needed() + assert started is True + + # Join the daemon thread deterministically. + for thread in threading.enumerate(): + if thread.name == "mlx-autorepair": + thread.join(timeout = 5) + + assert repaired["called"] is True + assert redetected["called"] is True + + +def test_attempts_only_once_per_process(monkeypatch): + monkeypatch.setattr(mr, "is_apple_silicon", lambda: True) + monkeypatch.setattr(mr, "mlx_stack_available", lambda: False) + monkeypatch.setattr(mr, "attempt_mlx_repair", lambda **_k: False) + + first = mr.start_mlx_autorepair_if_needed() + second = mr.start_mlx_autorepair_if_needed() + assert first is True + assert second is False # guard prevents a second concurrent attempt diff --git a/studio/backend/tests/test_model_defaults_none_guard.py b/studio/backend/tests/test_model_defaults_none_guard.py new file mode 100644 index 0000000000..0024ec2201 --- /dev/null +++ b/studio/backend/tests/test_model_defaults_none_guard.py @@ -0,0 +1,35 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""load_model_defaults must not raise on a None/empty model id. + +Before the guard, calling it before a model is selected logged +`Error loading model defaults for None: 'NoneType' object has no attribute 'lower'`. +""" + +from __future__ import annotations + +import logging +import sys +from pathlib import Path + +_BACKEND = Path(__file__).resolve().parent.parent +if str(_BACKEND) not in sys.path: + sys.path.insert(0, str(_BACKEND)) + +from utils.models.model_config import load_model_defaults # noqa: E402 + + +def test_none_and_empty_return_empty_without_error(caplog): + with caplog.at_level(logging.ERROR): + assert load_model_defaults(None) == {} # type: ignore[arg-type] + assert load_model_defaults("") == {} + assert "Error loading model defaults" not in caplog.text + assert "NoneType" not in caplog.text + + +def test_unknown_string_still_returns_defaults_dict(): + # A non-None unknown model name still resolves (falls back to default.yaml), + # i.e. the guard only short-circuits None/empty, nothing else. + result = load_model_defaults("definitely-not-a-real-model-xyz") + assert isinstance(result, dict) diff --git a/studio/backend/tests/test_startup_llama_probe_non_blocking.py b/studio/backend/tests/test_startup_llama_probe_non_blocking.py new file mode 100644 index 0000000000..eb5b8d0b5f --- /dev/null +++ b/studio/backend/tests/test_startup_llama_probe_non_blocking.py @@ -0,0 +1,98 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""The llama.cpp startup probes must run OFF the FastAPI lifespan critical path. + +Regression guard for the macOS slow-startup bug: the capability + freshness probes +(added in #5528/#5529) used to run inline in `lifespan`, so a cold/slow GitHub +freshness check blocked `Application startup complete` for tens of seconds. They now +run on a daemon thread, and are skipped entirely when update checks are disabled. +""" + +from __future__ import annotations + +import sys +import time +from pathlib import Path + +import pytest + +_BACKEND = Path(__file__).resolve().parent.parent +if str(_BACKEND) not in sys.path: + sys.path.insert(0, str(_BACKEND)) + +import main # noqa: E402 +import utils.llama_cpp_freshness as freshness # noqa: E402 +from core.inference.llama_cpp import LlamaCppBackend # noqa: E402 + +SLEEP = 5.0 + + +class _FakeApp: + class _State: + pass + + def __init__(self) -> None: + self.state = _FakeApp._State() + self.state.llama_cpp_capabilities = None + self.state.llama_cpp_freshness = None + + +@pytest.fixture(autouse = True) +def _fast_capability_probe(monkeypatch): + # Keep the (local) capability probe instant + offline so the freshness sleep + # is the only slow thing under test. + monkeypatch.setattr( + LlamaCppBackend, + "_find_llama_server_binary", + staticmethod(lambda: "/no/such/llama-server"), + ) + monkeypatch.setattr( + LlamaCppBackend, + "probe_server_capabilities", + staticmethod(lambda _b: {"found": False}), + ) + monkeypatch.delenv("UNSLOTH_DISABLE_UPDATE_CHECK", raising = False) + + +def test_probe_does_not_block_startup(monkeypatch): + """`_start_llama_cpp_probes_if_enabled` returns immediately even though the + freshness check sleeps for SLEEP seconds, then populates app.state later.""" + + def _slow_freshness(_bin, **_kw): + time.sleep(SLEEP) + return {"stale": False, "behind": False} + + monkeypatch.setattr(freshness, "check_prebuilt_freshness", _slow_freshness) + + app = _FakeApp() + t0 = time.monotonic() + main._start_llama_cpp_probes_if_enabled(app) + elapsed = time.monotonic() - t0 + + assert elapsed < 0.5, f"startup probe blocked the caller for {elapsed:.2f}s" + + # The daemon thread eventually populates app.state once the slow check returns. + deadline = time.monotonic() + SLEEP + 5 + while app.state.llama_cpp_freshness is None and time.monotonic() < deadline: + time.sleep(0.1) + assert app.state.llama_cpp_freshness == {"stale": False, "behind": False} + + +def test_disable_env_skips_probe_entirely(monkeypatch): + """UNSLOTH_DISABLE_UPDATE_CHECK=1 starts no probe thread and makes no call.""" + calls: list[int] = [] + + def _freshness(_bin, **_kw): + calls.append(1) + return {"stale": False} + + monkeypatch.setattr(freshness, "check_prebuilt_freshness", _freshness) + monkeypatch.setenv("UNSLOTH_DISABLE_UPDATE_CHECK", "1") + + app = _FakeApp() + main._start_llama_cpp_probes_if_enabled(app) + time.sleep(0.5) + + assert calls == [], "freshness check ran despite UNSLOTH_DISABLE_UPDATE_CHECK=1" + assert app.state.llama_cpp_freshness is None diff --git a/studio/backend/tests/test_utils.py b/studio/backend/tests/test_utils.py index cc7d04ca0a..64a3c62156 100644 --- a/studio/backend/tests/test_utils.py +++ b/studio/backend/tests/test_utils.py @@ -107,6 +107,7 @@ class TestGetDevice: patch("utils.hardware.hardware._has_torch", return_value = False), patch("utils.hardware.hardware.is_apple_silicon", return_value = True), patch("utils.hardware.hardware._has_mlx", return_value = True), + patch("utils.hardware.hardware._has_usable_mlx_stack", return_value = True), ): assert _reset_and_detect() == DeviceType.MLX diff --git a/studio/backend/utils/hardware/hardware.py b/studio/backend/utils/hardware/hardware.py index 02e177baf3..637679a4be 100644 --- a/studio/backend/utils/hardware/hardware.py +++ b/studio/backend/utils/hardware/hardware.py @@ -66,6 +66,11 @@ class DeviceType(str, Enum): DEVICE: Optional[DeviceType] = None CHAT_ONLY: bool = True # No CUDA GPU -> GGUF chat only (Mac, CPU-only, etc.) +# Why CHAT_ONLY is True (Train/Export disabled). None when training is enabled. +# "mlx_unavailable": Apple Silicon but the MLX stack is missing, too old, or broken +# (the usual cause of "Train/Export greyed out" on Macs after a reinstall dropped MLX); +# "intel_mac": Intel Mac (no PyTorch/MLX); "no_gpu": CPU-only non-Mac host. +CHAT_ONLY_REASON: Optional[str] = None IS_ROCM: bool = False # True when running on AMD ROCm (HIP) -- routes GPU monitoring to amd.py @@ -106,6 +111,24 @@ def _has_mlx() -> bool: return False +def _has_usable_mlx_stack() -> bool: + """True only when the FULL Studio MLX training/export stack is usable + (mlx + mlx-lm + mlx-vlm at the minimum versions unsloth-zoo requires), not + just a bare ``import mlx.core``. A backtracked/old mlx-vlm still imports but + breaks VLM Train/Export, so the training gate must match the self-heal's own + criterion (utils.mlx_repair.mlx_stack_available) -- otherwise detect_hardware + would enable Train/Export on exactly the inadequate stack the MLX self-heal + is trying to repair, leaving the user with greyed-in-but-broken buttons.""" + try: + from utils.mlx_repair import mlx_stack_available + return mlx_stack_available() + except Exception as exc: + # mlx_repair should always import; if it somehow cannot, fall back to the + # bare import check rather than forcing a working host into chat-only. + logger.debug("MLX stack availability check failed, using bare import: %s", exc) + return _has_mlx() + + def _print_cuda_device_list(is_rocm: bool) -> None: """List every visible CUDA/ROCm GPU with its index at startup. @@ -151,8 +174,9 @@ def detect_hardware() -> DeviceType: 2. MLX (Apple Silicon via MLX framework) 3. CPU (fallback) """ - global DEVICE, CHAT_ONLY, IS_ROCM - CHAT_ONLY = True # reset -- only CUDA/ROCm sets it to False + global DEVICE, CHAT_ONLY, CHAT_ONLY_REASON, IS_ROCM + CHAT_ONLY = True # reset -- only CUDA/ROCm/XPU/MLX sets it to False + CHAT_ONLY_REASON = None IS_ROCM = False # --- CUDA / ROCm: try PyTorch --- @@ -190,7 +214,10 @@ def detect_hardware() -> DeviceType: return DEVICE # --- MLX: Apple Silicon --- - if is_apple_silicon() and _has_mlx(): + # Require the full mlx/mlx-lm/mlx-vlm stack (not a bare `import mlx.core`) so + # the gate matches utils.mlx_repair: a partial/backtracked stack stays + # chat-only (reason "mlx_unavailable") and the background self-heal repairs it. + if is_apple_silicon() and _has_usable_mlx_stack(): DEVICE = DeviceType.MLX CHAT_ONLY = False # Use platform.machine() ("arm64"); platform.processor() returns "i386" @@ -201,6 +228,23 @@ def detect_hardware() -> DeviceType: # --- Fallback --- DEVICE = DeviceType.CPU + # CHAT_ONLY is still True here (every training-capable branch returned early), + # so record WHY so the UI can explain the greyed-out Train/Export instead of + # silently disabling them. + if is_apple_silicon(): + # Reached the CPU fallback on Apple Silicon, so the MLX stack is missing, + # too old, or broken. This is usually an environment problem recoverable + # with `unsloth studio update`. + CHAT_ONLY_REASON = "mlx_unavailable" + logger.warning( + "Apple Silicon detected but the MLX stack is incomplete or too old; " + "Train/Export disabled (chat-only). Run `unsloth studio update` to " + "restore MLX training." + ) + elif platform.system() == "Darwin": + CHAT_ONLY_REASON = "intel_mac" # Intel Mac: no PyTorch/MLX -> GGUF-only by design. + else: + CHAT_ONLY_REASON = "no_gpu" print("Hardware detected: CPU (no GPU backend available)") return DEVICE diff --git a/studio/backend/utils/mlx_repair.py b/studio/backend/utils/mlx_repair.py new file mode 100644 index 0000000000..06d0289166 --- /dev/null +++ b/studio/backend/utils/mlx_repair.py @@ -0,0 +1,279 @@ +# 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 + +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_PACKAGE_NAMES = tuple(_MLX_MIN_VERSIONS) +_MLX_RUNTIME_IMPORTS = ("mlx.core", "mlx_lm", "mlx_lm.sample_utils", "mlx_vlm") +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) +) +_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: + if Version(_dist_version(name)) < Version(minimum): + 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]: + """Environment for the mlx install. 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).""" + env = dict(os.environ) + override = ( + Path(__file__).resolve().parents[1] + / "requirements" + / "single-env" + / "overrides-darwin-arm64.txt" + ) + if override.is_file(): + env.setdefault("UV_OVERRIDE", str(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", *_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 diff --git a/studio/backend/utils/models/model_config.py b/studio/backend/utils/models/model_config.py index 11fe58e6c3..9401ba3427 100644 --- a/studio/backend/utils/models/model_config.py +++ b/studio/backend/utils/models/model_config.py @@ -2354,6 +2354,12 @@ def load_model_defaults(model_name: str) -> Dict[str, Any]: MODEL_NAME_MAPPING aliases, else falls back to default.yaml. Returns the parameter dict, or {} if none found. """ + # No model selected yet (or a non-string id): nothing to load. Guard before + # the .lower() calls below so this doesn't raise and get logged as + # "Error loading model defaults for None: 'NoneType' object has no attribute + # 'lower'". + if not isinstance(model_name, str) or not model_name: + return {} try: script_dir = Path(__file__).parent.parent.parent defaults_dir = script_dir / "assets" / "configs" / "model_defaults" diff --git a/studio/frontend/src/components/app-sidebar.tsx b/studio/frontend/src/components/app-sidebar.tsx index 00d6347a64..0497b64882 100644 --- a/studio/frontend/src/components/app-sidebar.tsx +++ b/studio/frontend/src/components/app-sidebar.tsx @@ -108,7 +108,7 @@ import { } from "@/features/chat"; import { useSettingsDialogStore } from "@/features/settings"; import { useEffectiveProfile, UserAvatar } from "@/features/profile"; -import { usePlatformStore } from "@/config/env"; +import { fetchDeviceType, usePlatformStore } from "@/config/env"; import { clearAuthTokens, logout } from "@/features/auth"; import { TOUR_OPEN_EVENT } from "@/features/tour"; import { @@ -211,6 +211,7 @@ function NavItem({ dataTour, className, spinner, + tooltip, }: { icon: typeof ZapIcon; label: string; @@ -221,12 +222,15 @@ function NavItem({ dataTour?: string; className?: string; spinner?: boolean; + // Overrides the hover tooltip (defaults to `label`). Used to explain why a + // disabled item (e.g. Train/Export on a chat-only host) is greyed out. + tooltip?: string; }) { return (
s.isChatOnly()); + const chatOnlyReason = usePlatformStore((s) => s.chatOnlyReason); + // When Train/Export are greyed out (chat-only host), explain why on hover + // instead of disabling them silently. mlx_unavailable is the common macOS case + // after a reinstall/update dropped MLX and is recoverable via `unsloth studio update`. + const trainExportDisabledHint: string | undefined = !chatOnly + ? undefined + : chatOnlyReason === "mlx_unavailable" + ? "Training needs MLX. Run `unsloth studio update` to enable Train and Export." + : chatOnlyReason === "intel_mac" + ? "Training needs Apple Silicon or a GPU. Intel Macs are chat-only." + : chatOnlyReason === "no_gpu" + ? "Training needs an NVIDIA or AMD GPU." + : undefined; + + // The backend MLX self-heal (utils/mlx_repair) can reinstall MLX in the + // background and flip chat_only false without a restart. The platform store + // cached the initial /api/health, so re-poll while we are chat-only for the + // recoverable mlx_unavailable case; the effect stops once Train/Export become + // available (chatOnly flips false and this effect's guard returns early). + useEffect(() => { + if (!chatOnly || chatOnlyReason !== "mlx_unavailable") return; + const id = window.setInterval(() => { + void fetchDeviceType({ force: true }).catch(() => undefined); + }, 15000); + return () => window.clearInterval(id); + }, [chatOnly, chatOnlyReason]); + const [shutdownOpen, setShutdownOpen] = useState(false); const isChatRoute = pathname.startsWith("/chat"); @@ -1105,6 +1136,7 @@ export function AppSidebar() { pathname === "/studio" || pathname.startsWith("/studio/") } disabled={chatOnly} + tooltip={trainExportDisabledHint} spinner={trainingInProgress} onClick={() => { if (chatOnly) return; @@ -1133,6 +1165,7 @@ export function AppSidebar() { label={t("shell.navigation.train")} active={pathname === "/studio" || pathname.startsWith("/studio/")} disabled={chatOnly} + tooltip={trainExportDisabledHint} spinner={trainingInProgress} onClick={() => { if (chatOnly) return; @@ -1154,6 +1187,7 @@ export function AppSidebar() { label={t("shell.navigation.export")} active={pathname === "/export" || pathname.startsWith("/export/")} disabled={chatOnly} + tooltip={trainExportDisabledHint} spinner={exportInProgress} onClick={() => { if (chatOnly) return; diff --git a/studio/frontend/src/components/ui/sidebar.tsx b/studio/frontend/src/components/ui/sidebar.tsx index 0a71753899..3a8694908f 100644 --- a/studio/frontend/src/components/ui/sidebar.tsx +++ b/studio/frontend/src/components/ui/sidebar.tsx @@ -571,6 +571,7 @@ function SidebarMenuButton({ } & VariantProps) { const Comp = asChild ? Slot.Root : "button" const { isMobile, state } = useSidebar() + const isDisabled = Boolean(props.disabled || props["aria-disabled"]) const button = ( fires no pointer events (and is not focusable), so the + // tooltip would never open. Wrap it in a focusable span with a hoverable box + // so the explanation (e.g. why Train/Export are greyed out) is reachable. + const trigger = isDisabled ? ( + + {button} + + ) : ( + button + ) + return ( - {button} + {trigger} diff --git a/studio/frontend/src/config/env.ts b/studio/frontend/src/config/env.ts index 60f9e876f9..def1b9bad9 100644 --- a/studio/frontend/src/config/env.ts +++ b/studio/frontend/src/config/env.ts @@ -18,6 +18,10 @@ export type DeviceType = "mac" | "windows" | "linux" | string; interface PlatformState { deviceType: DeviceType; chatOnly: boolean; + // Why chatOnly is set (null when training is enabled), from /api/health. + // e.g. "mlx_unavailable" on Apple Silicon -> the UI explains the greyed-out + // Train/Export instead of silently disabling them. + chatOnlyReason: string | null; // From /api/health (authed): live tunnel URL, direct (non-tunnel) base, and // whether the server was launched with --secure. cloudflareUrl: string | null; @@ -42,6 +46,7 @@ const localDeviceType = detectLocalPlatform(); export const usePlatformStore = create()((_, get) => ({ deviceType: localDeviceType, chatOnly: localDeviceType === "mac", + chatOnlyReason: null, cloudflareUrl: null, serverUrl: null, secure: false, @@ -71,18 +76,21 @@ export async function fetchDeviceType(options?: { const data = (await res.json()) as { device_type?: string; chat_only?: boolean; + chat_only_reason?: string | null; cloudflare_url?: string | null; server_url?: string | null; secure?: boolean; }; const deviceType = data.device_type ?? detectLocalPlatform(); const chatOnly = data.chat_only ?? false; + const chatOnlyReason = data.chat_only_reason ?? null; // Cache only a server-reported platform. Unauthenticated responses fall // back to the browser platform, which can differ from the host (WSL, // SSH); keeping fetched=false retries once a token exists. usePlatformStore.setState({ deviceType, chatOnly, + chatOnlyReason, cloudflareUrl: data.cloudflare_url ?? null, serverUrl: data.server_url ?? null, secure: data.secure ?? false, diff --git a/studio/install_llama_prebuilt.py b/studio/install_llama_prebuilt.py index 4edab0b8ab..569431b71e 100644 --- a/studio/install_llama_prebuilt.py +++ b/studio/install_llama_prebuilt.py @@ -179,9 +179,7 @@ DEFAULT_MAX_MACOS_RELEASE_FALLBACKS = env_int( 16, minimum = 1, ) -# Deterministic macOS pin. At b9428 ggml-org's macOS runner moved to macOS 26 -# (Tahoe), so b9428+ prebuilts only load on macOS 26+. b9415 is the last build -# stamped below 26 (arm64 minos 14, x64 minos 13.3); loads on macOS 13.3/14/15/26. +# Last upstream macOS release before ggml-org moved macOS builds to Tahoe. _PINNED_MACOS_FALLBACK_TAG = "b9415" _PINNED_MACOS_LATEST_FLOOR = (26, 0) FORCE_COMPILE_DEFAULT_REF = os.environ.get("UNSLOTH_LLAMA_FORCE_COMPILE_REF", "master") @@ -193,26 +191,12 @@ FORCE_COMPILE_DEFAULT_REF = os.environ.get("UNSLOTH_LLAMA_FORCE_COMPILE_REF", "m _MIN_CUDA_MAJOR = 12 _MAX_PROBE_CUDA_MAJOR = 19 -# Last ggml-org release whose Windows win-cuda-13 build is still sub-13.3 -# (cuda-13.1, b9360, 2026-05-27). Upstream bumped win-cuda-13 to 13.3 at b9365 -# and now ships only cuda-12.4 + cuda-13.3. cuda-12.4 predates Blackwell (ggml -# compiles sm_120 only at toolkit >= 12.8), so a Blackwell host on a 13.0/13.1/13.2 -# driver is gated off 13.3 and would drop to a CPU-only 12.4 build. b9360 is -# immutable, so we pin its cuda-13.1 build (plus paired cudart) as a GPU -# fallback for exactly those hosts. See unslothai/unsloth#5887. -_PINNED_BLACKWELL_FALLBACK_TAG = "b9360" -_PINNED_BLACKWELL_FALLBACK_RUNTIME = "13.1" -# Floor at 13.0: b9360 ships native sm_120a SASS (no PTX/JIT) and a bundled -# cuda-13.1 cudart, both of which run on a CUDA 13.0 r580+ driver via CUDA -# minor-version compatibility, so the mainstream 13.0 Blackwell branch is covered. -_PINNED_BLACKWELL_DRIVER_FLOOR = (13, 0) +# Blackwell sm_120 capability thresholds. A host is Blackwell when its highest +# compute capability is at least sm_120; ggml compiles sm_120 only at toolkit +# >= 12.8, so an in-release windows-cuda build at or above that already covers +# Blackwell, while cuda-12.4 does not and is dropped on a Blackwell host. _BLACKWELL_MIN_SM = 120 -# ggml compiles Blackwell sm_120 only at toolkit >= 12.8, so an in-release -# windows-cuda build at or above this already covers Blackwell and makes the -# older pinned 13.1 fallback unnecessary (cuda-12.4 is below it). _BLACKWELL_MIN_TOOLKIT = (12, 8) -_PINNED_BLACKWELL_LLAMA_SHA256 = "31ddb8b42d7ab4a47cab8c48c397519f580ca502df7e73f3ab396eacc16c8e8d" -_PINNED_BLACKWELL_CUDART_SHA256 = "f96935e7e385e3b2d0189239077c10fe8fd7e95690fea4afec455b1b6c7e3f18" def _cuda_runtime_lines_for_major(major: int) -> list[str]: @@ -1441,11 +1425,6 @@ def direct_upstream_release_plan( ) ) attempts[:] = _drop_blackwell_incapable_windows_cuda(host, attempts) - # Blackwell on a 13.1/13.2 driver: prefer the pinned cuda-13.1 GPU - # build over the CPU-only cuda-12.4 the in-release gating leaves. - pinned = _pinned_windows_cuda_fallback(host, attempts) - if pinned is not None: - attempts.insert(0, pinned) elif host.has_rocm: hip_asset = f"llama-{release_tag}-bin-win-hip-radeon-x64.zip" hip_url = assets.get(hip_asset) @@ -1571,19 +1550,9 @@ def direct_upstream_release_plan( def pinned_macos_release_tag(host: HostInfo, repo: str) -> str | None: - """Pin b9415 (the last upstream macOS build that loads below macOS 26) for a - known pre-26 host on ggml-org upstream; return None to keep latest selection. - The unslothai/llama.cpp fork ships its own prebuilts (arm64 minos 14, x64 - minos 13.3) and needs no pin, so this is a no-op there and for macOS 26+, - unknown version, non-macOS.""" - if repo != UPSTREAM_REPO: + if repo != UPSTREAM_REPO or not host.is_macos or host.macos_version is None: return None - if not host.is_macos: - return None - version = host.macos_version - if version is None: - return None - if version >= _PINNED_MACOS_LATEST_FLOOR: + if host.macos_version >= _PINNED_MACOS_LATEST_FLOOR: return None return _PINNED_MACOS_FALLBACK_TAG @@ -1610,9 +1579,6 @@ def resolve_simple_install_release_plans( ) requested_tag = normalized_requested_llama_tag(llama_tag) allow_older_release_fallback = requested_tag == "latest" and not published_release_tag - # macOS: pin the last upstream build that loads on a pre-26 host instead of - # fetching the latest (macOS 26 only) build and walking back release by - # release. No-op on macOS 26+, unknown version, non-macOS, and the fork. if allow_older_release_fallback: pinned_macos = pinned_macos_release_tag(host, repo) if pinned_macos is not None: @@ -3453,94 +3419,6 @@ def _drop_blackwell_incapable_windows_cuda( ] -def _pinned_windows_cuda_fallback( - host: HostInfo, existing_cuda_attempts: list[AssetChoice] -) -> AssetChoice | None: - """Pinned GPU fallback for a Blackwell host the in-release build gates off. - Upstream stopped publishing a sub-13.3 Windows cuda13 build after b9360, and - cuda-12.4 cannot offload sm_120, so a 13.1/13.2 driver would land on CPU. - b9360's cuda-13.1 build is immutable and runs on those drivers. Returns None - (dormant) whenever the in-release selection already offers a Blackwell-capable - build (toolkit >= 12.8, e.g. a runnable cuda13/cuda14), so it self-disables - once upstream ships a driver-runnable build again. - - The b9360 binary reuses the current release's source tree and convert scripts - and is recorded via binary_release_tag.""" - if not (host.is_windows and host.is_x86_64 and host.has_usable_nvidia): - return None - driver = host.driver_cuda_version - if driver is None or driver < _PINNED_BLACKWELL_DRIVER_FLOOR: - return None - caps = normalize_compute_caps(host.compute_caps) - if not caps or int(caps[-1]) < _BLACKWELL_MIN_SM: - return None - if any(_windows_cuda_attempt_covers_blackwell(attempt) for attempt in existing_cuda_attempts): - return None - tag = _PINNED_BLACKWELL_FALLBACK_TAG - runtime = _PINNED_BLACKWELL_FALLBACK_RUNTIME - base = ( - f"https://github.com/{UPSTREAM_REPO}/releases/download/" - f"{urllib.parse.quote(tag, safe = '')}" - ) - name = f"llama-{tag}-bin-win-cuda-{runtime}-x64.zip" - cudart_name = f"cudart-llama-bin-win-cuda-{runtime}-x64.zip" - return AssetChoice( - repo = UPSTREAM_REPO, - tag = tag, - name = name, - url = f"{base}/{name}", - source_label = "upstream", - install_kind = "windows-cuda", - runtime_line = "cuda13", - runtime_name = cudart_name, - runtime_url = f"{base}/{cudart_name}", - expected_sha256 = _PINNED_BLACKWELL_LLAMA_SHA256, - runtime_sha256 = _PINNED_BLACKWELL_CUDART_SHA256, - selection_log = [ - f"windows_cuda_selection: pinned {tag} cuda-{runtime} Blackwell GPU " - f"fallback (in-release cuda13 gated off by driver " - f"{driver[0]}.{driver[1]})" - ], - ) - - -def _augment_checksums_with_pin( - checksums: ApprovedReleaseChecksums, pin: AssetChoice -) -> ApprovedReleaseChecksums: - """Add the pin's own verified hashes to a copy of the approved checksums so - apply_approved_hashes keeps it on the published path (b9360 is not in the - release manifest).""" - artifacts = dict(checksums.artifacts) - if pin.expected_sha256: - artifacts[pin.name] = ApprovedArtifactHash( - asset_name = pin.name, - sha256 = pin.expected_sha256, - repo = pin.repo, - kind = "prebuilt", - ) - if pin.runtime_name and pin.runtime_sha256: - artifacts[pin.runtime_name] = ApprovedArtifactHash( - asset_name = pin.runtime_name, - sha256 = pin.runtime_sha256, - repo = pin.repo, - kind = "prebuilt", - ) - return dataclasses_replace(checksums, artifacts = artifacts) - - -def _with_pinned_windows_cuda_fallback( - host: HostInfo, attempts: list[AssetChoice], checksums: ApprovedReleaseChecksums -) -> tuple[list[AssetChoice], ApprovedReleaseChecksums]: - """Insert the Blackwell pin ahead of the Windows CUDA attempts and keep it - through apply_approved_hashes, or return the inputs unchanged when dormant. - Gives the published install path the same GPU fallback as the simple path.""" - attempts = _drop_blackwell_incapable_windows_cuda(host, attempts) - pin = _pinned_windows_cuda_fallback(host, attempts) - if pin is None: - return attempts, checksums - return [pin, *attempts], _augment_checksums_with_pin(checksums, pin) - - def published_windows_cuda_attempts( host: HostInfo, release: PublishedReleaseBundle, @@ -3966,10 +3844,18 @@ def resolve_upstream_asset_choice(host: HostInfo, llama_tag: str) -> AssetChoice if host.is_windows and host.is_x86_64: if host.has_usable_nvidia: - attempts = resolve_windows_cuda_choices(host, llama_tag, upstream_assets) + attempts = _drop_blackwell_incapable_windows_cuda( + host, resolve_windows_cuda_choices(host, llama_tag, upstream_assets) + ) if attempts: return attempts[0] - raise PrebuiltFallback("no compatible Windows CUDA asset was found") + # A Blackwell host left with only an sm_120-incapable cuda-12.4 build + # (upstream gated off 13.3) has no usable GPU prebuilt here; fall + # through to the CPU bundle rather than returning a build it cannot + # offload. A non-Blackwell NVIDIA host with no CUDA asset at all is + # still a hard fallback. + if not _host_is_blackwell(host): + raise PrebuiltFallback("no compatible Windows CUDA asset was found") # AMD ROCm on Windows: try upstream HIP prebuilt, then fall back to CPU if host.has_rocm: @@ -4050,23 +3936,20 @@ def resolve_release_asset_choice( torch_preference.selection_log, ) if published_attempts: - pin_attempts, pin_checksums = _with_pinned_windows_cuda_fallback( - host, published_attempts, checksums - ) + pin_attempts = _drop_blackwell_incapable_windows_cuda(host, published_attempts) try: - return apply_approved_hashes(pin_attempts, pin_checksums) + return apply_approved_hashes(pin_attempts, checksums) except PrebuiltFallback as exc: log( "published Windows CUDA assets ignored for install planning: " f"{release.repo}@{release.release_tag} ({exc})" ) upstream_assets = github_release_assets(UPSTREAM_REPO, llama_tag) - upstream_attempts, upstream_checksums = _with_pinned_windows_cuda_fallback( + upstream_attempts = _drop_blackwell_incapable_windows_cuda( host, resolve_windows_cuda_choices(host, llama_tag, upstream_assets), - checksums, ) - return apply_approved_hashes(upstream_attempts, upstream_checksums) + return apply_approved_hashes(upstream_attempts, checksums) published_choice: AssetChoice | None = None if host.is_windows and host.is_x86_64: diff --git a/tests/studio/install/test_macos_version_compat.py b/tests/studio/install/test_macos_version_compat.py index a8b57fe3c5..c2b2dc9225 100644 --- a/tests/studio/install/test_macos_version_compat.py +++ b/tests/studio/install/test_macos_version_compat.py @@ -223,7 +223,7 @@ def _fake_macos_releases(tags): class TestMacosReleasePin: - """A pre-26 macOS host pins the last upstream release that loads on it (b9415); macOS 26+ and unknown-version hosts use normal latest selection.""" + """Pre-26 upstream macOS pins the last loadable ggml-org release.""" TAGS = [f"b{n}" for n in range(9442, 9400, -1)] # newest-first, includes b9415 diff --git a/tests/studio/install/test_selection_logic.py b/tests/studio/install/test_selection_logic.py index 771b19c043..f65c7a895c 100644 --- a/tests/studio/install/test_selection_logic.py +++ b/tests/studio/install/test_selection_logic.py @@ -59,7 +59,6 @@ source_archive_logical_name = INSTALL_LLAMA_PREBUILT.source_archive_logical_name windows_cuda_upstream_asset_names = INSTALL_LLAMA_PREBUILT.windows_cuda_upstream_asset_names env_int = INSTALL_LLAMA_PREBUILT.env_int direct_upstream_release_plan = INSTALL_LLAMA_PREBUILT.direct_upstream_release_plan -_pinned_windows_cuda_fallback = INSTALL_LLAMA_PREBUILT._pinned_windows_cuda_fallback CudaRuntimePreference = INSTALL_LLAMA_PREBUILT.CudaRuntimePreference published_windows_cuda_attempts = INSTALL_LLAMA_PREBUILT.published_windows_cuda_attempts _windows_cuda_attempt_covers_blackwell = ( @@ -2295,84 +2294,15 @@ class TestWindowsCudaAttempts: # =========================================================================== -# N.1b. _pinned_windows_cuda_fallback -- pinned b9360 cuda-13.1 Blackwell fallback +# N.1b. _windows_cuda_attempt_covers_blackwell -- Blackwell coverage classifier # =========================================================================== -class TestPinnedBlackwellCudaFallback: - """Blackwell on 13.0/13.1/13.2, gated off in-release 13.3, gets the pinned b9360 cuda-13.1 GPU build; dormant otherwise.""" +class TestWindowsCudaAttemptCoversBlackwell: + """A windows-cuda attempt covers Blackwell only when its toolkit minor (from the asset name) is >= 12.8, or, for app-named bundles without a toolkit minor, its declared max_sm reaches sm_120.""" TAG = "b8508" - def _win_host(self, driver, caps): - return make_host( - system = "Windows", - machine = "AMD64", - driver_cuda_version = driver, - compute_caps = caps, - ) - - def test_pin_offered_for_driver_13_1_blackwell(self): - pin = _pinned_windows_cuda_fallback(self._win_host((13, 1), ["120"]), []) - assert pin is not None - assert pin.tag == "b9360" - assert pin.runtime_line == "cuda13" - assert pin.name == "llama-b9360-bin-win-cuda-13.1-x64.zip" - assert pin.runtime_name == "cudart-llama-bin-win-cuda-13.1-x64.zip" - assert pin.url.endswith("/b9360/llama-b9360-bin-win-cuda-13.1-x64.zip") - assert pin.runtime_url.endswith("/b9360/cudart-llama-bin-win-cuda-13.1-x64.zip") - assert pin.install_kind == "windows-cuda" - assert pin.expected_sha256 and len(pin.expected_sha256) == 64 - assert pin.runtime_sha256 and len(pin.runtime_sha256) == 64 - - def test_pin_offered_for_driver_13_2(self): - assert _pinned_windows_cuda_fallback(self._win_host((13, 2), ["120"]), []) is not None - - def test_pin_offered_for_sm121_variant(self): - # sm_121 is Blackwell-family, also needs toolkit >= 12.8. - assert _pinned_windows_cuda_fallback(self._win_host((13, 1), ["121"]), []) is not None - - def test_pin_uses_max_of_multi_gpu_caps(self): - assert _pinned_windows_cuda_fallback(self._win_host((13, 1), ["86", "120"]), []) is not None - - @pytest.mark.parametrize("sm", ["89", "90", "100"]) - def test_pin_not_offered_to_non_blackwell(self, sm): - # Ada/Hopper run cuda-12.4 fine; the pin must not fire. - assert _pinned_windows_cuda_fallback(self._win_host((13, 1), [sm]), []) is None - - def test_pin_offered_for_driver_13_0(self): - # b9360 native sm_120a SASS + cuda-13.1 cudart run on a 13.0 r580+ driver via - # minor-version compat; 13.0 is the mainstream Blackwell branch, so it must fire. - assert _pinned_windows_cuda_fallback(self._win_host((13, 0), ["120"]), []) is not None - - def test_pin_not_offered_below_floor(self): - # 12.x predates Blackwell; the pin stays dormant below 13.0. - assert _pinned_windows_cuda_fallback(self._win_host((12, 9), ["120"]), []) is None - - def test_pin_not_offered_without_driver(self): - assert _pinned_windows_cuda_fallback(self._win_host(None, ["120"]), []) is None - - def test_pin_not_offered_on_linux(self): - host = make_host( - system = "Linux", - machine = "x86_64", - driver_cuda_version = (13, 1), - compute_caps = ["120"], - ) - assert _pinned_windows_cuda_fallback(host, []) is None - - def test_pin_dormant_when_cuda13_attempt_present(self, monkeypatch): - # A runnable in-release cuda13 build makes the pin unnecessary. - mock_windows_runtime(monkeypatch, ["cuda13", "cuda12"]) - host = self._win_host((13, 1), ["120"]) - assets = { - f"llama-{self.TAG}-bin-win-cuda-13.1-x64.zip": "https://example.com/13.1", - f"llama-{self.TAG}-bin-win-cuda-12.4-x64.zip": "https://example.com/12.4", - } - existing = windows_cuda_attempts(host, self.TAG, assets, None) - assert any(a.runtime_line == "cuda13" for a in existing) - assert _pinned_windows_cuda_fallback(host, existing) is None - def _win_cuda_attempt(self, minor): major = minor.split(".")[0] return AssetChoice( @@ -2385,30 +2315,6 @@ class TestPinnedBlackwellCudaFallback: runtime_line = f"cuda{major}", ) - def test_pin_dormant_when_runnable_cuda14_present(self, monkeypatch): - # An in-release cuda14 build must take precedence over the older b9360 13.1 pin. - mock_windows_runtime(monkeypatch, ["cuda14", "cuda12"]) - host = self._win_host((14, 0), ["120"]) - assets = { - f"llama-{self.TAG}-bin-win-cuda-14.0-x64.zip": "https://example.com/14.0", - f"llama-{self.TAG}-bin-win-cuda-12.4-x64.zip": "https://example.com/12.4", - } - existing = windows_cuda_attempts(host, self.TAG, assets, None) - assert any(a.runtime_line == "cuda14" for a in existing) - assert _pinned_windows_cuda_fallback(host, existing) is None - - def test_pin_dormant_when_runnable_cuda12_8_present(self): - # A cuda-12.8 build also covers Blackwell, so the pin defers to it. - host = self._win_host((13, 1), ["120"]) - existing = [self._win_cuda_attempt("12.8")] - assert _pinned_windows_cuda_fallback(host, existing) is None - - def test_pin_fires_when_only_cuda12_4_present(self): - # cuda-12.4 does not cover Blackwell, so the pin still fires. - host = self._win_host((13, 1), ["120"]) - existing = [self._win_cuda_attempt("12.4")] - assert _pinned_windows_cuda_fallback(host, existing) is not None - @pytest.mark.parametrize( "minor, covers", [ @@ -2462,22 +2368,14 @@ class TestPinnedBlackwellCudaFallback: attempt = self._app_attempt(profile, runtime_line, max_sm) assert _windows_cuda_attempt_covers_blackwell(attempt) is covers - def test_pin_dormant_when_app_bundle_covers_blackwell(self): - # Regression: the coverage check previously matched only legacy - # -bin-win-cuda-X.Y-x64.zip names, so the pin never went dormant for an - # app-named cuda13 bundle that covers Blackwell. - host = self._win_host((13, 1), ["120"]) - existing = [self._app_attempt("newer", "cuda13", 120)] - assert _pinned_windows_cuda_fallback(host, existing) is None - # =========================================================================== -# N.1c. direct_upstream_release_plan -- pinned Blackwell fallback ordering +# N.1c. direct_upstream_release_plan -- Blackwell windows-cuda fallback ordering # =========================================================================== class TestDirectUpstreamBlackwellPin: - """The pin lands ahead of cuda-12.4 on the simple/upstream path; absent once a runnable in-release cuda13 build exists.""" + """On the simple/upstream path a Blackwell host drops the sm_120-incapable cuda-12.4 attempt; with no pinned fallback it falls through to the windows-cpu build, while an in-release cuda-13.3 build is taken when present.""" TAG = "b9365" @@ -2503,7 +2401,7 @@ class TestDirectUpstreamBlackwellPin: lambda host: CudaRuntimePreference(runtime_line = None, selection_log = []), ) - def test_blackwell_13_1_prepends_pin(self, monkeypatch): + def test_blackwell_13_1_falls_to_cpu(self, monkeypatch): mock_windows_runtime(monkeypatch, ["cuda13", "cuda12"]) self._no_torch(monkeypatch) host = make_host( @@ -2514,10 +2412,10 @@ class TestDirectUpstreamBlackwellPin: ) plan = direct_upstream_release_plan(self._release(), host, UPSTREAM_REPO, "latest") order = [(a.tag, a.runtime_line or a.install_kind) for a in plan.attempts] - # cuda-12.4 (no sm_120) is dropped entirely on Blackwell rather than left as a - # slow non-native fallback behind the pin. - assert order == [("b9360", "cuda13"), (self.TAG, "windows-cpu")] - assert plan.attempts[0].name == "llama-b9360-bin-win-cuda-13.1-x64.zip" + # cuda-12.4 (no sm_120) is dropped entirely on Blackwell, and there is no pinned + # b9360 fallback anymore, so the host falls through to the windows-cpu build. + assert order == [(self.TAG, "windows-cpu")] + assert "b9360" not in [a.tag for a in plan.attempts] # Direct/upstream path stays unverified-by-manifest (no approved hashes). assert plan.approved_checksums.artifacts == {} @@ -2819,7 +2717,7 @@ class TestPublishedWindowsCudaAttemptsDynamicMajor: class TestResolveReleaseAssetChoicePin: - """The manifest install path reaches the same b9360 Blackwell pin as the filename path, with its verified hash threaded.""" + """The manifest install path drops sm_120-incapable windows-cuda attempts on Blackwell exactly like the filename path; with no pinned fallback a 13.1 host left with only cuda-12.4 has no usable CUDA attempt and walks back, while a 13.3 host keeps its in-release cuda-13.3 build.""" TAG = "b8508" @@ -2858,9 +2756,14 @@ class TestResolveReleaseAssetChoicePin: lambda host: CudaRuntimePreference(runtime_line = None, selection_log = []), ) - def test_pin_applied_on_published_path_for_13_1(self, monkeypatch): + def test_no_cuda_attempt_on_published_path_for_13_1(self, monkeypatch): mock_windows_runtime(monkeypatch, ["cuda13", "cuda12"]) self._no_torch(monkeypatch) + # After the published Blackwell filter drops every attempt, the resolver + # walks back to the upstream release; stub that fetch so the unit test stays + # offline (a live GitHub call is blocked by the security scanner) and the + # walk-back deterministically finds no usable CUDA build -> PrebuiltFallback. + monkeypatch.setattr(INSTALL_LLAMA_PREBUILT, "github_release_assets", lambda repo, tag: {}) release = self._release([("13.3", "cuda13"), ("12.4", "cuda12")]) checksums = self._checksums(["12.4"]) # 13.3 gated off for a 13.1 driver host = make_host( @@ -2869,14 +2772,11 @@ class TestResolveReleaseAssetChoicePin: driver_cuda_version = (13, 1), compute_caps = ["120"], ) - result = resolve_release_asset_choice(host, self.TAG, release, checksums) - assert result[0].tag == "b9360" - assert result[0].name == "llama-b9360-bin-win-cuda-13.1-x64.zip" - # The pin survives the approved-hash gate with its verified hash threaded. - assert result[0].expected_sha256 and len(result[0].expected_sha256) == 64 - assert result[0].runtime_sha256 and len(result[0].runtime_sha256) == 64 - # The sm_120-incapable upstream cuda-12.4 zip is excluded on Blackwell. - assert not any(a.runtime_line == "cuda12" for a in result) + # The sm_120-incapable upstream cuda-12.4 zip is dropped on Blackwell, and there + # is no pinned b9360 fallback anymore, so no usable windows-cuda attempt remains + # and the manifest resolver walks back instead of selecting cuda-12.4. + with pytest.raises(PrebuiltFallback): + resolve_release_asset_choice(host, self.TAG, release, checksums) def test_pin_dormant_on_published_path_for_13_3(self, monkeypatch): mock_windows_runtime(monkeypatch, ["cuda13", "cuda12"]) @@ -3299,6 +3199,29 @@ class TestResolveUpstreamAssetChoice: assert result.install_kind == "macos-arm64" assert result.name == name + def test_windows_blackwell_drops_incapable_cuda_to_cpu(self, monkeypatch): + # A Blackwell host on a 13.1 driver gets cuda-13.3 gated off and only the + # sm_120-incapable cuda-12.4 build left; it must fall through to the CPU + # bundle rather than be handed a GPU build it cannot offload. + names = [ + f"llama-{self.TAG}-bin-win-cuda-13.3-x64.zip", + "cudart-llama-bin-win-cuda-13.3-x64.zip", + f"llama-{self.TAG}-bin-win-cuda-12.4-x64.zip", + "cudart-llama-bin-win-cuda-12.4-x64.zip", + f"llama-{self.TAG}-bin-win-cpu-x64.zip", + ] + self._mock_github_assets(monkeypatch, {n: f"https://x/{n}" for n in names}) + host = make_host( + system = "Windows", + machine = "AMD64", + has_usable_nvidia = True, + driver_cuda_version = (13, 1), + compute_caps = ["120"], + ) + result = resolve_upstream_asset_choice(host, self.TAG) + assert result.install_kind == "windows-cpu" + assert result.name == f"llama-{self.TAG}-bin-win-cpu-x64.zip" + def test_macos_arm64_missing(self, monkeypatch): self._mock_github_assets(monkeypatch, {}) host = make_host( @@ -3392,8 +3315,6 @@ def _macos_host(machine = "arm64", version = (15, 5)): class TestPinnedMacosReleaseTag: - """pinned_macos_release_tag: pin b9415 for ggml-org upstream macOS below 26; None (latest) for 26+, unknown version, the fork, non-macOS.""" - def test_arm64_sequoia_pins_b9415(self): host = _macos_host("arm64", (15, 5)) assert pinned_macos_release_tag(host, UPSTREAM_REPO) == "b9415" @@ -3403,24 +3324,18 @@ class TestPinnedMacosReleaseTag: assert pinned_macos_release_tag(host, UPSTREAM_REPO) == "b9415" def test_x64_ventura_13_3_pins_b9415(self): - # b9415's Intel slice is minos 13.3, so 13.3 Intel hosts load it. host = _macos_host("x86_64", (13, 3)) assert pinned_macos_release_tag(host, UPSTREAM_REPO) == "b9415" - def test_tahoe_26_0_takes_latest(self): + def test_tahoe_takes_latest(self): host = _macos_host("arm64", (26, 0)) assert pinned_macos_release_tag(host, UPSTREAM_REPO) is None - def test_tahoe_26_1_takes_latest(self): - host = _macos_host("arm64", (26, 1)) - assert pinned_macos_release_tag(host, UPSTREAM_REPO) is None - def test_unknown_version_takes_latest(self): host = _macos_host("arm64", None) assert pinned_macos_release_tag(host, UPSTREAM_REPO) is None def test_fork_repo_is_dormant(self): - # The fork publishes its own minos-13.3 prebuilts. host = _macos_host("arm64", (15, 5)) fork = INSTALL_LLAMA_PREBUILT.DEFAULT_PUBLISHED_REPO assert pinned_macos_release_tag(host, fork) is None @@ -3431,7 +3346,7 @@ class TestPinnedMacosReleaseTag: class TestResolveSimpleMacosPin: - """Simple/upstream path: a pre-26 host resolves b9415 (no walk-back); a macOS 26 host takes the latest release.""" + """Pre-26 upstream macOS resolves b9415; macOS 26 keeps latest.""" TAGS = ["b9442", "b9430", "b9428", "b9415"] # newest-first feed @@ -3478,9 +3393,7 @@ class TestResolveSimpleMacosPin: assert plans[0].llama_tag == "b9415" assert plans[0].attempts[0].install_kind == "macos-arm64" assert plans[0].attempts[0].name == "llama-b9415-bin-macos-arm64.tar.gz" - # The pin overrode the requested tag before any release was fetched. assert calls[0][2] == "b9415" - # Simple/upstream path stays unverified-by-manifest. assert plans[0].approved_checksums.artifacts == {} def test_tahoe_host_takes_latest_release(self, monkeypatch): diff --git a/tests/studio/test_hardware_dispatch_matrix.py b/tests/studio/test_hardware_dispatch_matrix.py index 62a0fe0447..bccddac967 100644 --- a/tests/studio/test_hardware_dispatch_matrix.py +++ b/tests/studio/test_hardware_dispatch_matrix.py @@ -204,6 +204,16 @@ def spoof_hardware(monkeypatch): fake_mlx.core = fake_mlx_core monkeypatch.setitem(sys.modules, "mlx", fake_mlx) monkeypatch.setitem(sys.modules, "mlx.core", fake_mlx_core) + # detect_hardware now gates MLX on the full stack via + # utils.mlx_repair.mlx_stack_available() (it imports mlx_lm/mlx_vlm and + # checks dist versions), which faking only mlx.core cannot satisfy. An + # mlx profile means a complete, healthy stack, so model that here; + # mlx_stack_available's own internals are covered by test_mlx_repair.py. + if str(STUDIO_BACKEND) not in sys.path: + sys.path.insert(0, str(STUDIO_BACKEND)) + import utils.mlx_repair as _mlx_repair # type: ignore + + monkeypatch.setattr(_mlx_repair, "mlx_stack_available", lambda: True) else: # Drop cached mlx and patch find_spec so the unsloth gate sees mlx as absent. monkeypatch.delitem(sys.modules, "mlx", raising = False) diff --git a/tests/studio/test_is_mlx_dispatch_gate.py b/tests/studio/test_is_mlx_dispatch_gate.py index 85a6e42449..f31f6d1655 100644 --- a/tests/studio/test_is_mlx_dispatch_gate.py +++ b/tests/studio/test_is_mlx_dispatch_gate.py @@ -178,6 +178,12 @@ def test_detect_hardware_picks_mlx_when_only_apple_silicon_available(monkeypatch monkeypatch.setitem(sys.modules, "mlx", fake_mlx) monkeypatch.setitem(sys.modules, "mlx.core", fake_mlx_core) + # detect_hardware now gates MLX on the full stack via _has_usable_mlx_stack() + # (utils.mlx_repair.mlx_stack_available imports mlx_lm/mlx_vlm and checks + # versions); faking mlx.core alone no longer satisfies it. This test asserts the + # dispatch decision when the stack IS usable, so model that directly. + monkeypatch.setattr(hw, "_has_usable_mlx_stack", lambda: True) + detected = hw.detect_hardware() assert detected == hw.DeviceType.MLX, f"expected MLX, got {detected!r}"