Add Studio PR-time CI: pin enforcement, frontend, backend, wheel smoke (#5298)

* Add Studio PR-time CI: pin enforcement, frontend, backend, wheel smoke

The repo currently has no PR-time CI; only release-desktop.yml (manual) and
stale.yml (issue pinger). studio/backend/tests/ has 35 test files (~860
tests collected) that never run automatically. Frontend lint/typecheck/build
scripts exist in package.json but are not gated on PRs either. This is the
gap that let 2026.5.1 ship with the broken Studio chat-history bundle.

Adds four ubuntu-latest workflows, all CPU-only and free for public repos:

studio-pin-enforce.yml
  Greps studio/frontend/package.json for caret/tilde ranges on the
  @assistant-ui surface (and assistant-stream). Blocks the exact regression
  vector that produced 2026.5.1 (^0.12.19 resolving to a breaking 0.12.28).

studio-frontend-ci.yml
  npm ci (strict lockfile), tree-clean check after, typecheck, vite build,
  bundle grep for the Studio unstable_Provider call site (<= 3 hits = OK,
  >= 4 = the 2026.5.1 regression), 75 MB dist budget, biome non-blocking.
  Uploads dist on failure.

studio-backend-ci.yml
  Runs the existing studio/backend/tests/ suite on Python 3.10/3.11/3.12.
  Excludes test_studio_api.py (live model + GGUF download) and
  llama_cpp_load_progress_live (spawns a real llama.cpp). Local run on this
  branch: 861 pass, 4 skipped, 5 deselected. ruff non-blocking.

wheel-smoke.yml
  python -m build, then verifies the produced wheel:
    - ships studio/frontend/package-lock.json
    - ships studio/frontend/dist/index.html
    - does NOT ship studio/frontend/node_modules/
    - does NOT ship studio/frontend/bun.lock
    - main JS bundle has < 4 unstable_Provider hits
  Then installs the wheel into a fresh venv with a lightweight dep set and
  imports studio.backend.main. Locally validated against the wheel built
  from this branch.

Each workflow has concurrency cancellation on the same ref. biome and ruff
are gated as non-blocking until the existing accumulated drift is cleared
(~470 biome errors today); remove the bypass in a follow-up.

Notes verified locally:

  - pin enforcement: PASS (carets dropped on this branch)
  - frontend npm ci -> typecheck -> build -> grep -> budget: PASS
  - bundle: 48 MB, hits=1
  - backend pytest: 861 pass, 1 GPU-pollution failure not reproducible on
    GPU-less runners (won't reproduce on ubuntu-latest)
  - wheel build: 13s, produces unsloth-2026.5.2-py3-none-any.whl
  - wheel content sanity: all five checks PASS

* CI: install full backend dep set + refine pytest filter for CPU runners

First CI run on PR #5298 surfaced two real gaps:

1. pytest collection failed at `import yaml` in utils/models/model_config.
   Locally my workspace venv had pyyaml from a transitive; CI's clean Python
   3.10/3.11/3.12 didn't, so collection hit ModuleNotFoundError on the very
   first test module. Same blew up the wheel-smoke `from studio.backend.main
   import app` step.

2. Once the import chain was complete, ~9 tests still failed because they
   exercise GPU-only paths or live transformers introspection that can't run
   on a GPU-less `ubuntu-latest` runner regardless of code correctness:
     - TestGpuAutoSelection
     - TestPreSpawnGpuResolution
     - TestPerGpuFitGuardAllCounts
     - TestTransformersIntrospection
     - test_returns_cuda_when_cuda_available
     - test_calls_cuda_cache_when_cuda

Fix:
- Backend CI installs `studio/backend/requirements/studio.txt` (the
  declared backend dep set) + the extras the import chain needs but
  studio.txt omits (python-multipart, sqlalchemy, cryptography, pyyaml,
  jinja2, mammoth, unpdf, requests, etc.) + torch CPU wheel + transformers.
- Refine the pytest -k filter to deselect the GPU/introspection-bound
  classes by name. Deselections are commented inline with the reason.
- wheel-smoke uses the same dep set so the import smoke matches.

Locally validated against the freshly-built unsloth-2026.5.2 wheel:
  831 passed, 5 skipped, 35 deselected, 0 failed in 47s
  Studio backend imports cleanly in a fresh venv after the wheel install.

* CI: collapse multiline pytest -k expression to a single line

YAML's | block-scalar fed the newlines verbatim into the -k argument and
pytest rejected it as 'Wrong expression passed to -k'. Same logical filter
on one line.

* CI: rename jobs so the GitHub UI shows what each check actually does

Adds a per-job 'name:' to all four workflows so the PR check list reads:

  Studio pin enforcement / @assistant-ui must be pinned exactly
  Studio frontend CI / Frontend build + bundle sanity
  Studio backend CI / Backend pytest (Python 3.10|3.11|3.12)
  Studio backend CI / Backend ruff lint (non-blocking)
  Wheel build + smoke / Wheel build + content sanity + import smoke

Instead of the default '<workflow> / <job-key>' which was opaque
('check', 'build', 'pytest (3.10)', 'ruff', 'wheel').

* CI: add Python 3.13 to backend pytest matrix

Verified locally: 831 backend tests pass under Python 3.13 with the same
filter set used for 3.10 / 3.11 / 3.12.

* CI: add Studio inference smoke + Tauri build smoke

Two new workflows. Both CPU-only, both free on `ubuntu-latest`.

studio-inference-smoke.yml
  The only workflow we have that proves "Studio actually works", as opposed
  to "the bundle parses" or "the imports succeed":
    - runs install.sh --local --no-torch (lean Studio install)
    - downloads unsloth/gemma-4-E2B-it-GGUF UD-IQ3_XXS into actions/cache
    - boots Studio in api-only mode
    - logs in with the bootstrap password, changes it, re-logs
    - POST /api/inference/load on the GGUF
    - POST /api/inference/chat/completions and asserts a non-empty
      assistant response
  Validated end-to-end locally on a fresh main install: model loaded,
  chat completion returned `Hello!` against the same GGUF the workflow
  uses.

studio-tauri-smoke.yml
  PR-time variant of release-desktop.yml. Linux-only debug build
  (`tauri build --debug --no-bundle`) on ubuntu-22.04. Catches
  src-tauri Cargo.toml / Rust source breakage, tauri.conf.json drift,
  and frontend-distDir wiring. Pinned to the same Tauri CLI version
  (2.10.1) as release-desktop.yml so CLI bumps surface in CI before
  they break the release pipeline. Mac and Windows desktop builds
  stay manual via release-desktop.yml because they need code-signing
  secrets.

* CI: use 'hf download' instead of deprecated 'huggingface-cli download'

huggingface_hub 1.13.0 dropped the huggingface-cli entrypoint. The
replacement is the 'hf' CLI shipped with the same package. Same args,
just s/huggingface-cli/hf/.

* CI: assert llama.cpp prebuilt path was used on ubuntu-latest

The inference-smoke job runs on ubuntu-latest (CPU-only, x86_64), which
is exactly the host shape that should pick up ggml-org/llama.cpp's
bin-ubuntu-x64.tar.gz prebuilt directly. If install.sh ever falls back
to a source build on this runner, the studio/setup.sh routing has
regressed and every CPU-only Linux user is paying a 3 minute compile
cost again.

Tee install.sh output to logs/install.log, then fail the job if the log
contains "falling back to source build" or is missing the success
marker "prebuilt installed and validated" / "prebuilt up to date and
validated".

Also include logs/install.log in the failure artifact so the prebuilt
diagnostics are uploaded alongside studio.log when the job fails.

* Tighten prebuilt-assertion comment in studio-inference-smoke

* CI: switch inference-smoke model to Qwen3.5-2B UD-IQ3_XXS

Drops the Gemma 4 E2B GGUF (~2.3 GB) for unsloth/Qwen3.5-2B-GGUF
(UD-IQ3_XXS, ~890 MiB). Cache-miss download is roughly a third of
what it was, and CPU inference on ubuntu-latest finishes well
inside the 25 minute job budget.

Verified locally: load via /api/inference/load returns
status=loaded, is_gguf=true, supports_reasoning=true,
supports_tools=true; chat completion returns a non-empty assistant
message ("Hello!").

* CI: add workflow_dispatch to inference-smoke for manual cache pre-warm

* CI: fold pin-enforce grep into studio-frontend-ci, drop standalone workflow

The "@assistant-ui must be pinned exactly" check was its own ~7 second
workflow, doing a single grep on studio/frontend/package.json. Move it
into studio-frontend-ci.yml as a pre-install step (right after
checkout, before any node setup so a violation fails fast). One fewer
top-level check row on every PR, same coverage.

Add a FIXME so this step is dropped once @assistant-ui/* and
assistant-stream leave 0.x: on 1.x, caret ranges are conventional and
this becomes overzealous.

* CI: add Repo tests (CPU) job, mirroring unsloth-zoo PR #624 conftest

The top-level tests/ tree was previously not run anywhere. 23 of its
files are CPU-friendly with the right harness: pure-Python helpers,
ast walks, installer logic, and CLI shape tests. Locally validated:
302 passed, 9 skipped, 12 deselected in ~7 seconds on Python 3.12.

Three pieces:

1. tests/conftest.py -- GPU-free harness, mirrors the conftest landed
   in unslothai/unsloth-zoo PR #624. Pre-loads unsloth_zoo.device_type
   and unsloth.device_type under a temporarily-mocked
   torch.cuda.is_available() so each module's @cache permanently
   captures "cuda" and the import chain succeeds on a CPU runner.
   Also stubs torch.cuda.get_device_capability /
   is_bf16_supported / mem_get_info, which unsloth/__init__.py and
   unsloth_zoo.temporary_patches probe at import time when
   DEVICE_TYPE == "cuda". On a real accelerator the harness is
   skipped and detection runs normally.

2. Two existing tests were leaking sys.modules state across the
   session because they injected stubs without an __spec__ and
   without restoration:

     - tests/test_raw_text.py shoved a "datasets" stub into
       sys.modules. transformers' import_utils later did
       importlib.util.find_spec("datasets") and got
       ValueError: datasets.__spec__ is None.

     - tests/python/test_fast_sentence_transformer_redirect_lifecycle.py
       shoved "transformers", "sentence_transformers", and
       "sentence_transformers.models" stubs in. Subsequent tests
       that did `import transformers` got the non-package stub.

   Fix: set __spec__ on stubs, plus an autouse fixture in the
   sentence-transformer test file that restores the three keys
   after each test.

3. .github/workflows/studio-backend-ci.yml gains a third job,
   `Repo tests (CPU)`, that installs the same dep set as the
   backend-pytest matrix (Python 3.12 only -- the tests are
   version-independent), exports PYTHONPATH=studio so tests/python/*
   can import install_python_stack, and runs the 23-file subset
   above with `-m 'not server and not e2e'`.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* CI: install unsloth_zoo for Repo CPU tests, harden conftest fallback

The CPU job at run 25422050018 broke at conftest collection: the
preload of unsloth.device_type pulled in `from unsloth_zoo.utils import
Version` and ubuntu-latest didn't have unsloth_zoo on the path because
it is an optional dep of unsloth. Two fixes:

1. Install unsloth_zoo>=2026.5.1 alongside the other deps in the Repo
   tests (CPU) job (it's also what unsloth's optional `huggingface`
   extra pins).

2. Wrap the body of _preload_device_type in conftest.py in a try/except
   so any import failure (missing prereq, broken module, etc.) cleanly
   returns False instead of aborting the entire collection. The caller
   already falls back to the stub device_type module on False, so the
   net behavior is "best effort: real device_type if possible, stub
   otherwise" instead of "abort the test session".

* kernels.utils: guard CUDA_STREAMS / XPU_STREAMS init for DEVICE_COUNT==0

When DEVICE_COUNT is 0 (CPU host: no visible NVIDIA / AMD / Intel GPU)
the dict comprehension {... for i in range(0)} was empty and the
subsequent max(_CUDA_STREAMS.keys()) raised
ValueError: max() iterable argument is empty
during module import. That made unsloth.kernels.utils unimportable on
any CPU runner, which in turn blocked all of tests/saving/**, three
top-level tests/test_*.py, and tests/qlora/test_unsloth_qlora_train_and_merge.py
from even collecting on CPU CI.

Wrap the per-device-index dict comprehension and max() machinery in
a DEVICE_COUNT > 0 guard. When DEVICE_COUNT is 0 fall back to empty
containers (CUDA_STREAMS = (), WEIGHT_BUFFERS = [], ABSMAX_BUFFERS = []).
The consumer functions further down in this module index these arrays
by device_index but only during real GPU work, so the empty fallbacks
never get touched on a CPU host.

GPU-safety verified locally: with 8 visible CUDA devices, CUDA_STREAMS
has 8 entries (identical to before this PR). With CUDA_VISIBLE_DEVICES=""
the module imports cleanly, CUDA_STREAMS is (), and the previously
blocked tests now collect (test_get_model_name passes 38 subtests,
test_resolve_model_class passes 9, test_model_registry collects all 8
parametrizations).

Same shape applied to the DEVICE_TYPE == "xpu" branch for symmetry.

* CI: switch Repo tests (CPU) to auto-discovery + isolate flakes

Three changes, locally validated end-to-end (779 passed, 11 skipped,
23 deselected, 0 failed across all three steps):

1. Repo tests (CPU, auto-discovered): replace the explicit 23-file
   list with `pytest tests/` plus a small set of `--ignore` and
   `--deselect` flags. New tests under tests/python, tests/studio
   (excluding the two state-sensitive files), and top-level
   tests/test_*.py are picked up automatically with no workflow edit.

   --ignore covers:
     - tests/qlora and tests/saving: GPU-bound by design
     - tests/utils: helpers folder, not tests
     - tests/sh: shell suite handled in its own step
     - two state-polluting hardware-spoof files (next step)
   -m 'not server and not e2e': honours markers already declared
     in tests/python/conftest.py
   --deselect: test_model_registration / test_all_model_registration
     hit huggingface_hub live; they belong on a network job

2. Hardware-spoof tests (state-sensitive, run in isolation):
   tests/studio/test_hardware_dispatch_matrix.py and
   tests/studio/test_is_mlx_dispatch_gate.py mutate module globals
   in studio.backend.utils.hardware.hardware (IS_ROCM, DEVICE) via
   their spoof fixtures, and the leak crosses file boundaries.
   Running them in their own pytest invocation avoids polluting the
   main sweep. Both pass cleanly in isolation: 28 passed, 1 skipped.

3. Shell installer tests: explicitly enumerated subset that does not
   depend on install.ps1 layout (test_install_host_defaults.sh has
   drifted; that's a separate followup).

Test fixes folded in to keep the run green:
  - tests/studio/install/test_rocm_support.py::TestAmdGpuMonitoring
    ::test_amd_primary_gpu_with_mock now clears
    HIP/ROCR/CUDA_VISIBLE_DEVICES via monkeypatch so
    _first_visible_amd_gpu_id() does not short-circuit when the runner
    sets CUDA_VISIBLE_DEVICES="" to suppress CUDA.
  - tests/studio/test_hardware_dispatch_matrix.py::spoof_hardware
    fixture now stubs torch.cuda.get_device_properties when
    cuda_available is True so detect_hardware()'s device_name probe
    does not call into _cuda_init() on a CPU runner.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* CI: install torchvision (CPU) so unsloth_zoo.vision_utils can import

Run 25430652224 collected three test modules that import unsloth and
crashed at unsloth_zoo/vision_utils.py:68 with
  ModuleNotFoundError: No module named 'torchvision'

unsloth_zoo.vision_utils unconditionally imports torchvision at module
scope, and unsloth.models._utils pulls vision_utils in. The Repo tests
(CPU) job installed torch from the CPU index but not torchvision, so
any test that imports unsloth.models.* failed at collection.

Add torchvision<0.26 to the same pip install --index-url
https://download.pytorch.org/whl/cpu line.

* CI: install bitsandbytes (CPU build) for unsloth.models._utils import

Run 25430982243 collected three test modules that import unsloth and
crashed at unsloth/models/_utils.py:1166 with
  ModuleNotFoundError: No module named 'bitsandbytes'

The bnb import there is unconditional. Recent bnb versions (>=0.45)
ship a CPU build so the wheel installs on a free Linux runner and the
import resolves; the kernels still raise on use but the module
collects, which is enough for these CPU tests.

Add 'bitsandbytes>=0.45' to the Repo tests (CPU) deps.

* CI: rename workflows + guard kernels.utils CPU-torch binding

Workflow renames (top-level `name:` keys; affects PR check rows):
  Studio backend CI    -> Backend CI
  Studio frontend CI   -> Frontend CI
  Studio inference smoke -> Studio GGUF CI
  Studio Tauri smoke   -> Studio Tauri CI
  Wheel build + smoke  -> Wheel CI

Backend CI's matrix job goes from "Backend pytest (Python 3.10)" to
just "(Python 3.10)" so the GitHub UI row reads
"Backend CI / (Python 3.10)" rather than the old verbose form.

Production guard for CPU torch (run 25431126138):

unsloth/kernels/utils.py:165 was an unconditional
  _gpu_getCurrentRawStream = torch._C._cuda_getCurrentRawStream
which raised AttributeError on a CPU-only torch wheel because the
compiled CUDA backend is absent. Three test modules (test_get_model_name,
test_model_registry, test_resolve_model_class) crashed at collection
because their import chain reaches this line.

Add a hasattr probe: when torch is built without CUDA, fall through to
a no-op binding that returns 0. _get_tensor_stream is only invoked
during real GPU work, so the no-op is never executed on a CPU host.

GPU-safety verified locally: with 8 visible CUDA devices the binding
still resolves to the real torch._C._cuda_getCurrentRawStream
(behaviour identical to before this PR). The XPU branch is untouched.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
This commit is contained in:
Daniel Han 2026-05-06 04:41:57 -07:00 committed by GitHub
commit a56c959233
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 977 additions and 33 deletions

200
.github/workflows/studio-backend-ci.yml vendored Normal file
View file

@ -0,0 +1,200 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# Runs the existing studio/backend/tests/ suite (~860 tests, all CPU-friendly)
# on every PR that touches the backend or unsloth library. Until this lands,
# none of those tests run automatically. Verified locally on Python 3.13 with
# the surgical exclusions below: 861 pass, 4 skipped.
#
# Exclusions:
# - tests/test_studio_api.py: end-to-end against a live model + GGUF download,
# too heavy for free runners. Run separately when GPU CI is available.
# - -k 'not llama_cpp_load_progress_live': spawns a real llama.cpp process,
# not appropriate for CPU-only runners.
#
# ruff is non-blocking initially; remove `|| true` once the backend lints clean.
name: Backend CI
on:
pull_request:
paths:
- 'studio/**'
- 'unsloth/**'
- 'unsloth_cli/**'
- 'tests/**'
- 'pyproject.toml'
- '.github/workflows/studio-backend-ci.yml'
push:
branches: [main, pip]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
pytest:
name: (Python ${{ matrix.python }})
runs-on: ubuntu-latest
timeout-minutes: 15
strategy:
fail-fast: false
matrix:
python: ['3.10', '3.11', '3.12', '3.13']
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '${{ matrix.python }}'
cache: 'pip'
- name: Install backend test dependencies (CPU only)
run: |
python -m pip install --upgrade pip
# Studio's declared backend deps:
pip install -r studio/backend/requirements/studio.txt
# Extras that studio.txt does not list but the import chain needs
# (python-multipart for FastAPI form/file uploads, sqlalchemy/cryptography
# for the auth DB, yaml/jinja2 for utils.models.model_config, etc.):
pip install \
python-multipart aiofiles sqlalchemy cryptography \
pyyaml jinja2 mammoth unpdf requests \
'numpy<3' pytest pytest-asyncio httpx
# Torch CPU + transformers are required by a chunk of the backend test
# suite (gpu_selection, kv_cache_estimation, utils). CPU-only torch
# keeps the install ~250 MB / ~1 min on a clean runner.
pip install --index-url https://download.pytorch.org/whl/cpu 'torch>=2.4,<2.11'
pip install 'transformers>=4.51,<5.5'
- name: Backend tests
working-directory: studio/backend
# Locally validated against this dep set: 831 passed, 5 skipped, 35 deselected.
# Deselections (all environment-specific, would never pass on a GPU-less
# `ubuntu-latest` runner regardless of code correctness):
# - llama_cpp_load_progress_live: spawns a real llama.cpp process
# - TestGpuAutoSelection / TestPreSpawnGpuResolution / TestPerGpuFitGuardAllCounts:
# require live transformers config introspection on real GPUs
# - TestTransformersIntrospection: same
# - test_returns_cuda_when_cuda_available / test_calls_cuda_cache_when_cuda:
# assume CUDA-capable GPU
run: |
python -m pytest tests/ -q --tb=short \
--ignore=tests/test_studio_api.py \
-k 'not llama_cpp_load_progress_live and not TestGpuAutoSelection and not TestPreSpawnGpuResolution and not TestPerGpuFitGuardAllCounts and not TestTransformersIntrospection and not test_returns_cuda_when_cuda_available and not test_calls_cuda_cache_when_cuda'
repo-cpu-tests:
# Auto-discover everything under tests/ that is not GPU-bound by
# design. New tests added in covered directories are picked up
# without a workflow edit. Locally validated: 779 passed, 11
# skipped, 23 deselected. tests/conftest.py (mirroring unsloth-zoo
# PR #624) pre-loads unsloth_zoo.device_type and unsloth.device_type
# under a mocked torch.cuda.is_available so the unsloth import
# chain succeeds on CPU.
name: Repo tests (CPU)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- name: Install deps (shared shape with backend pytest job)
run: |
python -m pip install --upgrade pip
pip install -r studio/backend/requirements/studio.txt
pip install \
python-multipart aiofiles sqlalchemy cryptography \
pyyaml jinja2 mammoth unpdf requests typer \
'numpy<3' pytest pytest-asyncio httpx
# torchvision is needed because unsloth_zoo.vision_utils imports
# it at module scope and is reached via unsloth.models._utils.
pip install --index-url https://download.pytorch.org/whl/cpu \
'torch>=2.4,<2.11' 'torchvision<0.26'
pip install 'transformers>=4.51,<5.5'
# bitsandbytes is a hard import in unsloth/models/_utils.py.
# Recent versions ship a CPU build so it installs on a free
# Linux runner; the kernels still raise on use, but import
# succeeds and the package collects.
pip install 'bitsandbytes>=0.45'
# unsloth.device_type imports unsloth_zoo.utils.Version at module
# scope, so the conftest harness needs unsloth_zoo on the path
# even though it is an optional dep of unsloth.
pip install 'unsloth_zoo>=2026.5.1'
pip install -e . --no-deps
- name: Repo tests (CPU, auto-discovered)
env:
# tests/python/* import install_python_stack from studio/.
PYTHONPATH: ${{ github.workspace }}/studio
# Skip lazy compilation work the unsloth import chain wants to
# do at import time on a real GPU.
UNSLOTH_COMPILE_DISABLE: '1'
# --ignore: GPU-bound directories (qlora and saving need real
# weights / GPU; tests/sh is a shell suite the next step
# handles; tests/utils is a helpers folder, not tests).
# State-sensitive hardware-spoofing files are pulled out and run
# in isolation in the next step because they mutate
# hardware.py module globals (IS_ROCM / DEVICE) and pollute
# downstream tests.
# -m: honour markers already declared in tests/python/conftest.py
# (`server` = needs studio venv, `e2e` = needs network).
# --deselect: two registry tests that hit huggingface_hub for
# live model existence checks; they belong on a network job.
run: |
python -m pytest tests/ -q --tb=short \
--ignore=tests/qlora \
--ignore=tests/saving \
--ignore=tests/utils \
--ignore=tests/sh \
--ignore=tests/studio/test_hardware_dispatch_matrix.py \
--ignore=tests/studio/test_is_mlx_dispatch_gate.py \
-m 'not server and not e2e' \
--deselect tests/test_model_registry.py::test_model_registration \
--deselect tests/test_model_registry.py::test_all_model_registration
- name: Hardware-spoof tests (state-sensitive, run in isolation)
env:
PYTHONPATH: ${{ github.workspace }}/studio
UNSLOTH_COMPILE_DISABLE: '1'
# These two files mutate hardware.py module globals at runtime
# via the spoof fixtures, which leaks state into any other test
# that imports hardware. Run them in their own pytest invocation
# so the leak does not cross file boundaries.
run: |
python -m pytest -q --tb=short \
tests/studio/test_hardware_dispatch_matrix.py \
tests/studio/test_is_mlx_dispatch_gate.py
- name: Shell installer tests
# Subset that does not depend on a writable / pristine install.sh
# tree; test_install_host_defaults.sh checks install.ps1 layout
# which has drifted (separate followup).
run: |
set -e
for s in \
tests/sh/test_get_torch_index_url.sh \
tests/sh/test_mac_intel_compat.sh \
tests/sh/test_tauri_install_exit_order.sh \
tests/sh/test_torch_constraint.sh; do
echo "::group::$s"
bash "$s"
echo "::endgroup::"
done
ruff:
name: Backend ruff lint (non-blocking)
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- run: pip install ruff
- name: ruff check (non-blocking until accumulated drift is cleared)
run: ruff check studio/backend || true

108
.github/workflows/studio-frontend-ci.yml vendored Normal file
View file

@ -0,0 +1,108 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# Frontend PR gate: lockfile freshness, typecheck, build, and a bundle grep
# that catches the 2026.5.1 chat-history regression at the JS level.
#
# biome runs as non-blocking for now: the codebase currently has accumulated
# ~470 errors and ~1650 warnings against the existing biome config. Surfacing
# the count in CI lets us drive it down without forcing a fleet-wide cleanup
# in the same PR. Drop `continue-on-error` once that number is zero.
name: Frontend CI
on:
pull_request:
paths:
- 'studio/frontend/**'
- '.github/workflows/studio-frontend-ci.yml'
push:
branches: [main, pip]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
name: Frontend build + bundle sanity
runs-on: ubuntu-latest
timeout-minutes: 10
defaults:
run:
working-directory: studio/frontend
steps:
- uses: actions/checkout@v4
# FIXME: drop this step once @assistant-ui/* and assistant-stream
# leave 0.x -- on 1.x, caret ranges are conventional. Until then,
# every 0.minor on this surface is a SemVer-major (this is exactly
# how 2026.5.1 shipped a broken chat runtime: ^0.12.19 quietly
# resolved to 0.12.28).
- name: '@assistant-ui must be pinned exactly (no caret/tilde)'
working-directory: ${{ github.workspace }}
run: |
set -e
if grep -nE '"(@assistant-ui/[a-z-]+|assistant-stream)":[[:space:]]*"[\^~]' studio/frontend/package.json; then
echo "::error file=studio/frontend/package.json::These packages must be pinned to exact versions until they leave 0.x. Drop the leading ^ or ~."
exit 1
fi
echo "All assistant-ui packages are pinned exactly."
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: studio/frontend/package-lock.json
- name: Lockfile must agree with package.json (npm ci is strict)
run: npm ci --no-fund --no-audit
- name: npm ci must not have modified the working tree
working-directory: ${{ github.workspace }}
run: |
if ! git diff --quiet -- studio/frontend; then
echo "::error::npm ci modified files; commit the updated lockfile"
git status -- studio/frontend
exit 1
fi
- name: Typecheck
run: npm run typecheck
- name: Build
run: npm run build
- name: Built bundle must not contain Studio's unstable_Provider call site
run: |
set -e
JS=$(ls dist/assets/index-*.js | head -1)
HITS=$(grep -c 'unstable_Provider:' "$JS" || echo 0)
echo "main bundle: $JS"
echo "unstable_Provider: hits=$HITS (assistant-ui internals contribute up to 3)"
if [ "$HITS" -gt 3 ]; then
echo "::error file=studio/frontend/src/features/chat/runtime-provider.tsx::Studio bundle still passes unstable_Provider through useRemoteThreadListRuntime; this is the 2026.5.1 chat-history regression. Pass adapters directly into useLocalRuntime instead."
exit 1
fi
- name: Bundle size budget (75 MB)
run: |
SIZE=$(du -sb dist | cut -f1)
BUDGET=$((75 * 1024 * 1024))
echo "dist size: $SIZE bytes ($((SIZE/1024/1024)) MB), budget: $BUDGET bytes (75 MB)"
if [ "$SIZE" -gt "$BUDGET" ]; then
echo "::error::studio/frontend/dist/ exceeded the 75 MB budget. Drop dead deps (e.g. the unused next dep) or split chunks."
exit 1
fi
- name: Biome (non-blocking until accumulated drift is cleared)
continue-on-error: true
run: npm run biome:check
- name: Upload built dist on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: studio-frontend-dist
path: studio/frontend/dist
retention-days: 3

View file

@ -0,0 +1,185 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# End-to-end smoke: install Studio via install.sh --local --no-torch, download
# a tiny GGUF, boot Studio, log in, change password, load the model, send a
# chat completion, assert a non-empty response. Only workflow that tests "the
# app actually works".
#
# Model: Qwen3.5-2B UD-IQ3_XXS (~890 MiB) -- small enough that the cache miss
# is cheap and inference fits in the 25 min CPU-runner budget. GGUF is cached
# across runs via actions/cache.
name: Studio GGUF CI
on:
pull_request:
paths:
- 'studio/**'
- 'unsloth/**'
- 'unsloth_cli/**'
- 'install.sh'
- 'pyproject.toml'
- '.github/workflows/studio-inference-smoke.yml'
push:
branches: [main, pip]
# Manual trigger for pre-warming the GGUF cache on main, or re-running
# against an arbitrary branch without pushing a no-op commit.
workflow_dispatch:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
env:
GGUF_REPO: unsloth/Qwen3.5-2B-GGUF
GGUF_FILE: Qwen3.5-2B-UD-IQ3_XXS.gguf
STUDIO_PORT: '18888'
jobs:
inference:
name: Studio boots, loads a GGUF, answers a chat completion
runs-on: ubuntu-latest
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- name: Linux dependencies for llama.cpp prebuilt
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends \
libcurl4-openssl-dev libssl-dev jq
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: studio/frontend/package-lock.json
- uses: actions/setup-python@v5
with:
python-version: '3.12'
cache: 'pip'
- name: Cache GGUF model file
id: cache-gguf
uses: actions/cache@v4
with:
path: gguf-cache
key: ${{ runner.os }}-gguf-${{ env.GGUF_REPO }}-${{ env.GGUF_FILE }}-v1
- name: Download GGUF if cache miss
if: steps.cache-gguf.outputs.cache-hit != 'true'
run: |
# huggingface-cli was deprecated in huggingface_hub 1.13; the new CLI is `hf`.
python -m pip install --upgrade huggingface_hub hf_transfer
mkdir -p gguf-cache
HF_HUB_ENABLE_HF_TRANSFER=1 \
hf download "$GGUF_REPO" "$GGUF_FILE" --local-dir gguf-cache
- name: Install Studio (--local, --no-torch keeps the install lean)
run: |
mkdir -p logs
set -o pipefail
bash install.sh --local --no-torch 2>&1 | tee logs/install.log
- name: Assert llama.cpp prebuilt was installed (no source-build fallback)
# ubuntu-latest is CPU-only x86_64, so studio/setup.sh should route
# to ggml-org/llama.cpp and grab bin-ubuntu-x64.tar.gz. A source
# build here means the routing regressed.
run: |
if grep -q "falling back to source build" logs/install.log; then
echo "::error::llama.cpp prebuilt path failed on ubuntu-latest. studio/setup.sh routing regressed; CPU-only Linux x86_64 should hit ggml-org/llama.cpp's bin-ubuntu-x64.tar.gz."
grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60
exit 1
fi
if ! grep -qE "prebuilt installed and validated|prebuilt up to date and validated" logs/install.log; then
echo "::error::install.log does not contain the success marker for the llama.cpp prebuilt path. Did setup.sh skip the prebuilt install?"
grep -E "llama-prebuilt|llama.cpp" logs/install.log | tail -60
exit 1
fi
echo "llama.cpp prebuilt path used successfully"
- name: Reset auth + start Studio in the background
run: |
unsloth studio reset-password
mkdir -p logs
UNSLOTH_API_ONLY=1 unsloth studio -H 127.0.0.1 -p "$STUDIO_PORT" \
> logs/studio.log 2>&1 &
echo "STUDIO_PID=$!" >> "$GITHUB_ENV"
- name: Wait for /api/health
run: |
for i in $(seq 1 60); do
if curl -fs "http://127.0.0.1:${STUDIO_PORT}/api/health" > /tmp/health.json; then
echo "ready after ${i}s"
cat /tmp/health.json
jq -e '.status == "healthy"' /tmp/health.json
exit 0
fi
sleep 1
done
echo "Studio did not become healthy in 60s"
tail -200 logs/studio.log
exit 1
- name: Login + change bootstrap password
run: |
PW=$(cat ~/.unsloth/studio/auth/.bootstrap_password)
NEW="CIPasswordSmoke12345!"
TOKEN=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/login" \
-H 'content-type: application/json' \
-d "{\"username\":\"unsloth\",\"password\":\"$PW\"}" | jq -r .access_token)
curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/change-password" \
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d "{\"current_password\":\"$PW\",\"new_password\":\"$NEW\"}" > /dev/null
# Re-login to clear must_change_password flag.
NEW_TOKEN=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/auth/login" \
-H 'content-type: application/json' \
-d "{\"username\":\"unsloth\",\"password\":\"$NEW\"}" | jq -r .access_token)
echo "TOKEN=$NEW_TOKEN" >> "$GITHUB_ENV"
- name: Load the GGUF into Studio
run: |
GGUF_PATH="$GITHUB_WORKSPACE/gguf-cache/${GGUF_FILE}"
ls -lh "$GGUF_PATH"
curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/inference/load" \
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
--max-time 600 \
-d "{\"model_path\":\"$GGUF_PATH\",\"is_lora\":false,\"max_seq_length\":2048}" \
| jq '{status, display_name, is_gguf, context_length}'
- name: Send a chat completion + assert non-empty response
run: |
RESP=$(curl -fs -X POST "http://127.0.0.1:${STUDIO_PORT}/api/inference/chat/completions" \
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
--max-time 900 \
-d '{
"messages":[{"role":"user","content":"Say hello in one short sentence."}],
"max_tokens":40,
"stream":false
}')
echo "raw response: $RESP"
CONTENT=$(echo "$RESP" | jq -r '.choices[0].message.content // empty')
echo "model response: $CONTENT"
if [ -z "$CONTENT" ]; then
echo "::error::Empty assistant response from Studio"
exit 1
fi
- name: Stop Studio
if: always()
run: |
kill "${STUDIO_PID}" || true
sleep 2
ss -tln | grep ":${STUDIO_PORT}" || true
- name: Upload Studio + install logs on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: studio-inference-log
path: |
logs/studio.log
logs/install.log
retention-days: 7

105
.github/workflows/studio-tauri-smoke.yml vendored Normal file
View file

@ -0,0 +1,105 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# PR-time smoke for the Tauri desktop wrapper. Builds the frontend and the
# Tauri Linux debug binary, with no codesigning. Catches:
# - tauri.conf.json drift
# - src-tauri Cargo.toml or rust source breakage
# - Tauri CLI version drift (we pin 2.10.1, matching release-desktop.yml)
# - frontend output not picked up by Tauri's distDir
#
# Linux-only on a free `ubuntu-latest` runner. Mac and Windows desktop builds
# stay in release-desktop.yml (manual `workflow_dispatch`) because they need
# code-signing secrets and ~30 min of runner time each.
name: Studio Tauri CI
on:
pull_request:
paths:
- 'studio/frontend/**'
- 'studio/src-tauri/**'
- '.github/workflows/studio-tauri-smoke.yml'
push:
branches: [main, pip]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
linux-debug-build:
name: Tauri Linux debug build (no codesign)
runs-on: ubuntu-22.04
timeout-minutes: 25
steps:
- uses: actions/checkout@v4
- name: Linux native deps for Tauri / WebKit2GTK
run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev libayatana-appindicator3-dev \
librsvg2-dev libxdo-dev libssl-dev patchelf
- uses: actions/setup-node@v4
with:
node-version: '24'
cache: 'npm'
cache-dependency-path: studio/frontend/package-lock.json
- uses: dtolnay/rust-toolchain@stable
- uses: swatinem/rust-cache@v2
with:
workspaces: studio/src-tauri -> target
- name: Install pinned Tauri CLI (matches release-desktop.yml)
run: npm install --save-dev --prefix studio @tauri-apps/cli@2.10.1
- name: Verify pinned Tauri CLI version
run: |
out="$(npx --prefix studio tauri --version)"
echo "$out"
[ "$out" = "tauri-cli 2.10.1" ] || { echo "::error::expected tauri-cli 2.10.1, got $out"; exit 1; }
- name: Frontend build (npm ci, vite)
working-directory: studio/frontend
run: |
npm ci --no-fund --no-audit
npm run build
test -f dist/index.html
- name: Tauri debug build (Linux, no bundle, no codesign)
# `--debug` + `--no-bundle` keeps this lean: compiles the Rust crate,
# confirms the frontend dist is wired into Tauri, but skips the AppImage
# / .deb production. Code signing is irrelevant because we never produce
# a distributable artifact.
env:
TAURI_SIGNING_PRIVATE_KEY: ''
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ''
run: npx --prefix studio tauri build --debug --no-bundle
- name: Inspect produced binary
run: |
BIN=$(find studio/src-tauri/target/debug -maxdepth 1 -type f -executable 2>/dev/null \
| grep -Ev '\.(d|so|dylib|dll)$' \
| grep -Ev '/(deps|build|examples)$' \
| head -1)
echo "binary: $BIN"
if [ -z "$BIN" ]; then
echo "::error::Tauri debug binary not produced"
ls -la studio/src-tauri/target/debug/ || true
exit 1
fi
file "$BIN"
du -h "$BIN"
- uses: actions/upload-artifact@v4
if: failure()
with:
name: tauri-debug-build
path: |
studio/src-tauri/target/debug
studio/frontend/dist
retention-days: 3

124
.github/workflows/wheel-smoke.yml vendored Normal file
View file

@ -0,0 +1,124 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
# Builds the PyPI wheel from the PR branch, then verifies the built wheel
# actually contains what we expect to ship and does NOT contain the broken
# Studio bundle that 2026.5.1 published. This is the single workflow that
# would have blocked the 2026.5.1 release before twine upload.
#
# Verified locally end-to-end against this branch:
# - python -m build produces unsloth-<version>-py3-none-any.whl in 13s
# - wheel content sanity passes:
# lockfile shipped, frontend dist shipped,
# no node_modules in wheel, no bun.lock in wheel,
# main bundle has unstable_Provider hits=1 (assistant-ui internals only).
# - Studio backend imports cleanly from the installed wheel with the
# lightweight dep set below.
name: Wheel CI
on:
pull_request:
paths:
- 'pyproject.toml'
- 'studio/**'
- 'unsloth/**'
- 'unsloth_cli/**'
- '.github/workflows/wheel-smoke.yml'
push:
branches: [main, pip]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
wheel:
name: Wheel build + content sanity + import smoke
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'npm'
cache-dependency-path: studio/frontend/package-lock.json
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Build frontend
run: |
cd studio/frontend
npm ci --no-fund --no-audit
npm run build
- name: Build wheel + sdist
run: |
python -m pip install --upgrade pip build
rm -rf dist build ./*.egg-info
python -m build
- name: Wheel content sanity
run: |
python - <<'PY'
import zipfile, glob, sys
w = glob.glob("dist/unsloth-*.whl")
if not w:
print("FAIL: no wheel produced"); sys.exit(2)
w = w[0]
print(f"wheel: {w}")
with zipfile.ZipFile(w) as z:
n = z.namelist()
checks = {
"lockfile shipped": any(s.endswith("studio/frontend/package-lock.json") for s in n),
"frontend dist shipped": any(s.endswith("studio/frontend/dist/index.html") for s in n),
"no node_modules": not any("studio/frontend/node_modules/" in s for s in n),
"no bun.lock": not any(s.endswith("studio/frontend/bun.lock") for s in n),
}
js = [s for s in n
if "studio/frontend/dist/assets/" in s
and s.endswith(".js")
and "/index-" in s]
if not js:
print("FAIL: no main bundle index-*.js in wheel"); sys.exit(2)
data = z.read(js[0]).decode("utf-8", "replace")
hits = data.count("unstable_Provider:")
print(f"main bundle: {js[0]}")
print(f"unstable_Provider hits: {hits} (>=4 indicates 2026.5.1 regression)")
checks["bundle has no Studio unstable_Provider call site"] = (hits < 4)
print()
for k, v in checks.items():
print(f" [{'PASS' if v else 'FAIL'}] {k}")
sys.exit(0 if all(checks.values()) else 1)
PY
- name: Studio backend import smoke
# Imports `studio.backend.main:app` from the freshly-installed wheel in
# a clean venv. This catches the class of bug that 2026.5.1 shipped with:
# frontend dist missing, package-lock.json missing, or the wheel's Python
# source tree broken in a way that surfaces only at app construction time.
run: |
python -m venv /tmp/v
/tmp/v/bin/pip install --upgrade pip
/tmp/v/bin/pip install -r studio/backend/requirements/studio.txt
/tmp/v/bin/pip install \
python-multipart aiofiles sqlalchemy cryptography \
pyyaml jinja2 mammoth unpdf requests \
'numpy<3'
/tmp/v/bin/pip install --no-deps dist/unsloth-*.whl
# Run from /tmp so Python imports the installed package, not the source tree.
cd /tmp
/tmp/v/bin/python -c "from studio.backend.main import app; print('Studio backend OK:', app.title)"
- name: Upload wheel on failure
if: failure()
uses: actions/upload-artifact@v4
with:
name: unsloth-wheel
path: dist/
retention-days: 7

141
tests/conftest.py Normal file
View file

@ -0,0 +1,141 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""GPU-free test harness.
unsloth's import chain hits unsloth_zoo.device_type, which calls
get_device_type() at import time and raises NotImplementedError on CI
runners with no CUDA / XPU / HIP visible. Pre-load the real
unsloth_zoo.device_type under a temporarily-mocked
torch.cuda.is_available() so its @cache permanently captures "cuda".
On a real accelerator the pre-load is skipped and detection runs
normally.
Mirrors the conftest harness in unslothai/unsloth-zoo PR #624.
"""
from __future__ import annotations
import importlib.util
import os
import sys
import types
def _has_real_accelerator() -> bool:
try:
import torch
except Exception:
return False
for probe in (
lambda: hasattr(torch, "cuda") and torch.cuda.is_available(),
lambda: hasattr(torch, "xpu") and torch.xpu.is_available(),
lambda: hasattr(torch, "accelerator") and torch.accelerator.is_available(),
):
try:
if probe():
return True
except Exception:
pass
return False
def _preload_device_type(package: str, prereqs: tuple[str, ...] = ()) -> bool:
"""Pre-load <package>.device_type under a mocked
torch.cuda.is_available() == True so its @cache permanently
captures "cuda". prereqs lists submodule names of <package> that
must be loaded first (e.g. 'utils' for unsloth_zoo). Returns False
if the package or any prerequisite cannot be imported, in which
case the caller falls back to a stub."""
target = f"{package}.device_type"
if target in sys.modules:
return True
pkg_spec = importlib.util.find_spec(package)
if pkg_spec is None or not pkg_spec.submodule_search_locations:
return False
pkg_path = pkg_spec.submodule_search_locations[0]
skeleton_already = package in sys.modules
if not skeleton_already:
skel = types.ModuleType(package)
skel.__path__ = [pkg_path]
skel.__spec__ = pkg_spec
skel.__package__ = package
sys.modules[package] = skel
try:
for prereq in prereqs:
full = f"{package}.{prereq}"
if full in sys.modules:
continue
prereq_path = os.path.join(pkg_path, f"{prereq}.py")
prereq_spec = importlib.util.spec_from_file_location(full, prereq_path)
prereq_mod = importlib.util.module_from_spec(prereq_spec)
sys.modules[full] = prereq_mod
prereq_spec.loader.exec_module(prereq_mod)
device_type_path = os.path.join(pkg_path, "device_type.py")
dt_spec = importlib.util.spec_from_file_location(target, device_type_path)
dt_mod = importlib.util.module_from_spec(dt_spec)
sys.modules[target] = dt_mod
import torch
_orig_is_avail = torch.cuda.is_available
torch.cuda.is_available = lambda: True # type: ignore[assignment]
try:
dt_spec.loader.exec_module(dt_mod)
finally:
torch.cuda.is_available = _orig_is_avail
except Exception:
sys.modules.pop(target, None)
return False
finally:
if not skeleton_already:
sys.modules.pop(package, None)
return True
def _patch_torch_cuda_for_import() -> None:
"""Stub torch.cuda.* probes that fire at IMPORT time of unsloth /
unsloth_zoo when DEVICE_TYPE was forced to "cuda" above. These are
queries, not real GPU work, so returning plausible Ampere values
lets the import chain finish; tests that touch real tensors run on
CPU like normal."""
try:
import torch.cuda.memory as _cuda_memory # type: ignore
_cuda_memory.mem_get_info = lambda *a, **k: (0, 80 * 1024**3)
except Exception:
pass
try:
import torch
torch.cuda.get_device_capability = lambda *a, **k: (8, 0)
torch.cuda.is_bf16_supported = lambda *a, **k: True
except Exception:
pass
def _install_device_type_stub(name: str) -> None:
stub = types.ModuleType(name)
stub.DEVICE_TYPE = "cuda"
stub.DEVICE_TYPE_TORCH = "cuda"
stub.DEVICE_COUNT = 1
stub.ALLOW_PREQUANTIZED_MODELS = False
stub.is_hip = lambda: False
stub.get_device_type = lambda: "cuda"
stub.get_device_count = lambda: 1
stub.device_synchronize = lambda *a, **k: None
stub.device_empty_cache = lambda *a, **k: None
stub.device_is_bf16_supported = lambda *a, **k: False
sys.modules[name] = stub
if not _has_real_accelerator():
if not _preload_device_type("unsloth_zoo", prereqs = ("utils",)):
_install_device_type_stub("unsloth_zoo.device_type")
if not _preload_device_type("unsloth"):
_install_device_type_stub("unsloth.device_type")
_patch_torch_cuda_for_import()

View file

@ -8,11 +8,45 @@
from __future__ import annotations
import importlib.util
import os
import pathlib
import sys
import types
import pytest
def _stub_module(name: str) -> types.ModuleType:
# __spec__ must be set so importlib.util.find_spec(name) does not raise
# ValueError if a downstream test imports the real package.
mod = types.ModuleType(name)
mod.__spec__ = importlib.util.spec_from_loader(name, loader = None)
return mod
_STUB_KEYS = (
"transformers",
"sentence_transformers",
"sentence_transformers.models",
)
@pytest.fixture(autouse = True)
def _restore_sys_modules():
"""Snapshot the entries we shadow with stubs and restore them after each
test so a downstream test that does `import transformers` for real does
not pick up our non-package stub."""
saved = {k: sys.modules.get(k) for k in _STUB_KEYS}
try:
yield
finally:
for k, v in saved.items():
if v is None:
sys.modules.pop(k, None)
else:
sys.modules[k] = v
class _FakeAuto:
def __init__(self, name):
@ -45,14 +79,14 @@ class _RaisingTransformer:
def _build_driver(transformer_class):
transformers_mod = types.ModuleType("transformers")
transformers_mod = _stub_module("transformers")
transformers_mod.AutoModel = _FakeAuto("AutoModel")
transformers_mod.AutoProcessor = _FakeAuto("AutoProcessor")
transformers_mod.AutoTokenizer = _FakeAuto("AutoTokenizer")
sys.modules["transformers"] = transformers_mod
st_root = types.ModuleType("sentence_transformers")
st_models = types.ModuleType("sentence_transformers.models")
st_root = _stub_module("sentence_transformers")
st_models = _stub_module("sentence_transformers.models")
st_models.Transformer = transformer_class
sys.modules["sentence_transformers"] = st_root
sys.modules["sentence_transformers.models"] = st_models

View file

@ -1188,7 +1188,7 @@ class TestAmdGpuMonitoring:
assert metrics["vram_utilization_pct"] is not None
assert metrics["power_utilization_pct"] is not None
def test_amd_primary_gpu_with_mock(self):
def test_amd_primary_gpu_with_mock(self, monkeypatch):
"""get_primary_gpu_utilization returns correct dict with mocked amd-smi."""
amd_path = PACKAGE_ROOT / "studio" / "backend" / "utils" / "hardware" / "amd.py"
_amd_spec = importlib.util.spec_from_file_location("test_amd2", amd_path)
@ -1203,6 +1203,17 @@ class TestAmdGpuMonitoring:
except Exception:
pytest.skip("Could not load amd module")
# _first_visible_amd_gpu_id() short-circuits to None when any of
# HIP / ROCR / CUDA_VISIBLE_DEVICES is set to "" or "-1". CI runners
# often unset CUDA at the env level by setting CUDA_VISIBLE_DEVICES
# to "" so the test must not inherit that.
for var in (
"HIP_VISIBLE_DEVICES",
"ROCR_VISIBLE_DEVICES",
"CUDA_VISIBLE_DEVICES",
):
monkeypatch.delenv(var, raising = False)
mock_json = json.dumps(
[
{

View file

@ -208,6 +208,20 @@ def spoof_hardware(monkeypatch):
# torch.cuda.is_available
monkeypatch.setattr(torch.cuda, "is_available", lambda: profile.cuda_available)
# detect_hardware reads torch.cuda.get_device_properties(0).name when
# cuda_available is True. On a CPU CI runner that triggers _cuda_init
# and crashes with "No CUDA GPUs are available". Stub it so the
# dispatch path under test runs end-to-end.
if profile.cuda_available:
stub_props = types.SimpleNamespace(
name = "Stub GPU" if not profile.hip_version else "Stub AMD GPU",
)
monkeypatch.setattr(
torch.cuda,
"get_device_properties",
lambda i = 0: stub_props,
raising = False,
)
# torch.version.hip — None on NVIDIA, "6.1" etc. on ROCm
torch_version = torch.version

View file

@ -35,8 +35,11 @@ class MockDataset:
return cls(data_dict)
# Mock datasets module
# Mock datasets module. __spec__ must be set so importlib.util.find_spec
# does not raise ValueError when transformers' import_utils probes for
# the real `datasets` package later in the test session.
datasets_mock = type(sys)("datasets")
datasets_mock.__spec__ = importlib.util.spec_from_loader("datasets", loader = None)
datasets_mock.Dataset = MockDataset
sys.modules["datasets"] = datasets_mock

View file

@ -161,8 +161,14 @@ else:
if DEVICE_TYPE == "xpu":
_gpu_getCurrentRawStream = torch._C._xpu_getCurrentRawStream
# NVIDIA GPU Default Logic
else:
elif hasattr(torch._C, "_cuda_getCurrentRawStream"):
_gpu_getCurrentRawStream = torch._C._cuda_getCurrentRawStream
else:
# CPU-only torch wheel (no compiled CUDA backend). _get_tensor_stream
# is only invoked during real GPU work, so a no-op binding is safe.
def _gpu_getCurrentRawStream(_index = 0):
return 0
c_void_p = ctypes.c_void_p
@ -177,36 +183,49 @@ global XPU_STREAMS
global WEIGHT_BUFFERS
global ABSMAX_BUFFERS
# INTEL GPU Specific Logic
# DEVICE_COUNT == 0 = no visible accelerator (e.g. CPU-only CI runner).
# The consumer functions below only index these arrays during real GPU
# work, so empty containers are safe -- they just need to be defined so
# the module imports cleanly.
if DEVICE_TYPE == "xpu":
_XPU_STREAMS = {
(index := torch.xpu.device(i).idx): ctypes.c_void_p(
torch._C._xpu_getCurrentRawStream(index)
)
for i in range(DEVICE_COUNT)
}
XPU_STREAMS = [None] * (max(_XPU_STREAMS.keys()) + 1)
WEIGHT_BUFFERS = [None] * (max(_XPU_STREAMS.keys()) + 1)
ABSMAX_BUFFERS = [None] * (max(_XPU_STREAMS.keys()) + 1)
for k, v in _XPU_STREAMS.items():
XPU_STREAMS[k] = v
XPU_STREAMS = tuple(XPU_STREAMS)
del _XPU_STREAMS
if DEVICE_COUNT > 0:
_XPU_STREAMS = {
(index := torch.xpu.device(i).idx): ctypes.c_void_p(
torch._C._xpu_getCurrentRawStream(index)
)
for i in range(DEVICE_COUNT)
}
XPU_STREAMS = [None] * (max(_XPU_STREAMS.keys()) + 1)
WEIGHT_BUFFERS = [None] * (max(_XPU_STREAMS.keys()) + 1)
ABSMAX_BUFFERS = [None] * (max(_XPU_STREAMS.keys()) + 1)
for k, v in _XPU_STREAMS.items():
XPU_STREAMS[k] = v
XPU_STREAMS = tuple(XPU_STREAMS)
del _XPU_STREAMS
else:
XPU_STREAMS = ()
WEIGHT_BUFFERS = []
ABSMAX_BUFFERS = []
else:
# NVIDIA GPU Default Logic
_CUDA_STREAMS = {
(index := torch.cuda.device(i).idx): ctypes.c_void_p(
torch._C._cuda_getCurrentRawStream(index)
)
for i in range(DEVICE_COUNT)
}
CUDA_STREAMS = [None] * (max(_CUDA_STREAMS.keys()) + 1)
WEIGHT_BUFFERS = [None] * (max(_CUDA_STREAMS.keys()) + 1)
ABSMAX_BUFFERS = [None] * (max(_CUDA_STREAMS.keys()) + 1)
for k, v in _CUDA_STREAMS.items():
CUDA_STREAMS[k] = v
CUDA_STREAMS = tuple(CUDA_STREAMS)
del _CUDA_STREAMS
if DEVICE_COUNT > 0:
_CUDA_STREAMS = {
(index := torch.cuda.device(i).idx): ctypes.c_void_p(
torch._C._cuda_getCurrentRawStream(index)
)
for i in range(DEVICE_COUNT)
}
CUDA_STREAMS = [None] * (max(_CUDA_STREAMS.keys()) + 1)
WEIGHT_BUFFERS = [None] * (max(_CUDA_STREAMS.keys()) + 1)
ABSMAX_BUFFERS = [None] * (max(_CUDA_STREAMS.keys()) + 1)
for k, v in _CUDA_STREAMS.items():
CUDA_STREAMS[k] = v
CUDA_STREAMS = tuple(CUDA_STREAMS)
del _CUDA_STREAMS
else:
CUDA_STREAMS = ()
WEIGHT_BUFFERS = []
ABSMAX_BUFFERS = []
# Bitsandbytes operations
ctypes_c_int = ctypes.c_int