unsloth/docker/unsloth_nb_compat.py
Daniel Han c9ff52ba04 docker: Colab-grade JupyterLab and Studio UX for the Blackwell image
Stacks a Colab-like JupyterLab and Studio experience on top of the
existing Blackwell image. Additive only: the training stack, CUDA/torch
pinning, and the Studio/JupyterLab/sshd service trio are unchanged.

JupyterLab labextension (prebuilt in a throwaway builder stage, so the
runtime image stays Node-free):
  - Unsloth Dark (Monokai) theme, adaptive light/dark by system preference
  - Colab-style ArrowDown/Up cell navigation
  - top-bar Unsloth logo (stock Jupyter logo disabled and locked)
  - #@title lines render as collapsible Heading-2 form bars
  - Ctrl+A in a cell output selects only that output, not the whole
    notebook (the old behaviour ran notebook:select-all and was laggy)
  - right activity bar hidden by default
  - overrides.json: per-cell run button without auto-advance, labeled
    Restart and Run All, windowing off so collapsing an output does not
    snap to the cell top, news/update prompts suppressed

Studio and login branding: Unsloth favicon, page logo, and a dark
Unsloth login page that rotates through the curated Studio sloth
stickers (fail-soft to the logo).

Notebook organization and Colab compatibility (base image):
  - categorized folder view built from relative symlinks mirroring the
    README sections, rebuilt each boot; real .ipynb files never moved,
    and the symlink tree is invisible to the sync state machine
  - AMD-* notebooks shown only on an AMD/HIP host (autodetected)
  - Docker-only strip of the Colab "Run all on Colab" intro sentence
    from unedited notebooks (upstream notebooks unchanged)
  - hoist %%capture above a leading #@title form so the cell runs
  - the per-cell transformers-sidecar log is silent unless
    UNSLOTH_ENABLE_LOGGING=1

Dependency pinning and naming: the curated notebook extras are pinned to
their resolved versions for reproducible rebuilds; decord is split into
its own fail-soft install (no aarch64 wheel). The lean base image is
renamed from :base to :core.

Adds tests/validate_studio_features.py, a static self-test for the
labextension plugins, overrides keys, and branding wiring.
2026-06-25 16:22:41 +00:00

154 lines
5.8 KiB
Python

"""Per-notebook transformers version activation for the Unsloth Docker image.
Problem: unslothai/notebooks pin many different transformers versions in their
install cells (transformers==4.56.2 on ~115, 5.5.0/5.3.0/5.10.x on newer model
families). The baked base venv ships ONE transformers (latest 5.x). Running an
old-model notebook against it, or letting the install cell pip-install a pinned
version on top, either breaks the model or clobbers the cu128 torch/vLLM stack.
Solution (mirrors Unsloth Studio's studio/backend/utils/transformers_version.py):
keep the base venv intact and ship coherent transformers "sidecars" -- each is a
`pip install --target <dir> --no-deps transformers==X` plus the matched
huggingface_hub/tokenizers/safetensors. To use version X we just prepend its
sidecar dir to sys.path BEFORE transformers is imported; the rest of the stack
(torch, vllm, unsloth, peft, trl) comes from the base venv unchanged. Verified:
base unsloth loads + generates under both a 4.57.6 and a 5.5.0 sidecar on B200.
Two activation paths:
* driven/headless: `unsloth-run <notebook>` sets PYTHONPATH at kernel launch.
* manual JupyterLab: an IPython pre_run_cell hook (registered by the baked
startup file) activates the sidecar before the first model cell, using the
version the notebook's own install cell asked for (recorded by the pip shim).
"""
from __future__ import annotations
import os, sys, glob, json
SIDECAR_ROOT = os.environ.get("UNSLOTH_TF_SIDECAR_ROOT", "/opt/unsloth-venv/tf-sidecars")
# The pip/uv shim writes the transformers version a notebook asked for here.
MARKER = os.environ.get("UNSLOTH_NB_TF_MARKER", "/tmp/unsloth_nb/requested_transformers")
def _logging_enabled() -> bool:
"""Sidecar activation is silent by default; users found the per-cell
`[unsloth-nb] activated transformers sidecar ...` line noisy. Set
UNSLOTH_ENABLE_LOGGING=1 to surface it (and other [unsloth-nb] diagnostics)."""
return os.environ.get("UNSLOTH_ENABLE_LOGGING", "").strip().lower() not in (
"", "0", "false", "no", "off",
)
# Model-name -> minimum transformers tier, ported from Studio's
# transformers_version.py (substring match on the lowered model id). Used as a
# fallback when a notebook does not pin transformers but names a new-family model.
_TIER_SUBSTRINGS = {
"5.10.2": ("gemma-4-12b", "gemma4-12b"),
"5.5.0": ("gemma-4", "gemma4", "qwen3.6"),
"5.3.0": (
"ministral-3",
"glm-4.7-flash",
"qwen3-30b-a3b",
"qwen3.5",
"qwen3-next",
"qwen3_5",
"lfm2.5-vl",
),
}
def _baked():
"""Return {version_str: dir} for every baked sidecar."""
out = {}
for d in sorted(glob.glob(os.path.join(SIDECAR_ROOT, "t_*"))):
out[os.path.basename(d)[2:].replace("_", ".")] = d
return out
def tier_for_model(model_name: str):
"""Best-effort minimum transformers version for a model id (or None)."""
if not model_name:
return None
low = model_name.lower()
# check newest tiers first so gemma-4-12b wins over gemma-4
for ver in ("5.10.2", "5.5.0", "5.3.0"):
if any(s in low for s in _TIER_SUBSTRINGS[ver]):
return ver
return None
def sidecar_for(version: str):
"""Map a requested/needed transformers version to a baked sidecar dir.
Uses ceiling semantics: the smallest baked version >= the request, because a
model added in version X needs *at least* X. If the request is newer than
every baked sidecar, return None -> use the base venv (the newest 5.x)."""
baked = _baked()
if not baked or not version:
return None
if version in baked:
return baked[version]
try:
from packaging.version import Version
want = Version(version)
except Exception:
return None
ge = sorted((Version(v), d) for v, d in baked.items() if Version(v) >= want)
return ge[0][1] if ge else None
def requested_version():
"""transformers version a notebook asked for (recorded by the pip shim)."""
try:
with open(MARKER) as f:
v = f.read().strip()
return v or None
except OSError:
return None
def activate(version: str | None, *, quiet: bool = False):
"""Prepend the matching sidecar to sys.path if transformers isn't imported yet.
Returns the activated dir, or None if the base venv is used / activation is
no longer possible (transformers already imported)."""
if not version:
return None
d = sidecar_for(version)
if not d:
return None
if "transformers" in sys.modules:
if not quiet:
print(
f"[unsloth-nb] transformers already imported; cannot switch to "
f"{version} in-process (restart the kernel, or use `unsloth-run`).",
file = sys.stderr,
)
return None
if d not in sys.path:
sys.path.insert(0, d)
os.environ["PYTHONPATH"] = d + os.pathsep + os.environ.get("PYTHONPATH", "")
if not quiet and _logging_enabled():
print(f"[unsloth-nb] activated transformers sidecar for {version}: {d}")
return d
def resolve(model_name: str | None = None):
"""Resolve the version to use: the notebook's pin first, else the model tier."""
return requested_version() or tier_for_model(model_name or "")
# -- manual JupyterLab integration: activate before the first model cell --------
def _pre_run_cell(_info = None):
v = requested_version()
if v and "transformers" not in sys.modules:
activate(v)
def register_ipython():
"""Register the pre_run_cell hook (called from the baked IPython startup)."""
try:
ip = get_ipython() # noqa: F821 (provided by IPython)
except NameError:
return
if ip is not None and not getattr(ip, "_unsloth_tf_hook", False):
ip.events.register("pre_run_cell", _pre_run_cell)
ip._unsloth_tf_hook = True