# SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 """ Main FastAPI application for Unsloth UI Backend """ import os import sys from pathlib import Path as _Path # Suppress annoying C-level dependency warnings globally os.environ["PYTHONWARNINGS"] = "ignore" # ── Windows AMD ROCm DLL injection ────────────────────────────────────────── # Python 3.8+ ignores PATH for extension modules; register ROCm bin dirs with # os.add_dll_directory() so amdhip64.dll etc. are found before any torch import. if sys.platform == "win32": # Retained at module scope -- os.add_dll_directory returns a handle that # removes the search-path entry when garbage collected. _ROCM_DLL_HANDLES: list = [] def _add_rocm_dll_dirs() -> None: candidates = [] # 1. HIP_PATH / ROCM_PATH -- set by the AMD HIP SDK installer for _var in ("HIP_PATH", "ROCM_PATH"): _val = os.environ.get(_var) if _val: candidates.append(os.path.join(_val, "bin")) # 2. Standard AMD installer location: C:\Program Files\AMD\ROCm\\bin # Scan all installed versions, newest first. _default_root = os.path.join( os.environ.get("ProgramFiles", r"C:\Program Files"), "AMD", "ROCm" ) def _ver_key(name: str) -> tuple: # Numeric tuple key so "10.0" sorts after "7.0"; non-numeric chunks fall back to string. parts = [] for chunk in name.split("."): try: parts.append((0, int(chunk))) except ValueError: parts.append((1, chunk)) return tuple(parts) try: if os.path.isdir(_default_root): for _ver in sorted( os.listdir(_default_root), key = _ver_key, reverse = True ): _bin = os.path.join(_default_root, _ver, "bin") if os.path.isdir(_bin): candidates.append(_bin) except OSError: pass for _d in candidates: if os.path.isdir(_d): try: _ROCM_DLL_HANDLES.append(os.add_dll_directory(_d)) except (OSError, AttributeError): pass _add_rocm_dll_dirs() del _add_rocm_dll_dirs # ── Windows AMD ROCm: set BNB_ROCM_VERSION before any bitsandbytes import ─ # bitsandbytes on Windows ROCm tries to load libbitsandbytes_rocm.dll # where comes from torch.version.hip (e.g. "7.13..." → "713"). # The installed BNB wheel ships rocm72.dll (not rocm713.dll), so without # this the server process crashes with "Configured ROCm binary not found". # Detect the available DLL, fall back to "72", and set BNB_ROCM_VERSION # before any import that pulls in bitsandbytes (mirrors worker.py logic). # Gate on the rocm bnb DLL (the exact file this configures) or HIP_PATH/ # ROCM_PATH, not on torch.version.hip: that needed importing torch on every # Windows host (NVIDIA/CPU included), adding seconds to startup. Radeon # wheels without HIP_PATH still ship the rocm bnb DLL, so they are covered. if "BNB_ROCM_VERSION" not in os.environ: import glob as _glob import logging as _logging _hip_env = bool(os.environ.get("HIP_PATH") or os.environ.get("ROCM_PATH")) _bnb_rocm_ver = None _found_rocm_bnb = False try: import importlib.util as _ilu _bnb_spec = _ilu.find_spec("bitsandbytes") # submodule_search_locations (not spec.origin) handles editable installs. if _bnb_spec and _bnb_spec.submodule_search_locations: import re as _re_bnb _all_vers_main: list[str] = [] for _pkg_dir in _bnb_spec.submodule_search_locations: for _dll in _glob.glob( os.path.join(_pkg_dir, "libbitsandbytes_rocm*.dll") ): _found_rocm_bnb = True _km = _re_bnb.search( r"libbitsandbytes_rocm(\d+)\.dll", os.path.basename(_dll) ) if _km: _all_vers_main.append(_km.group(1)) if _all_vers_main: _bnb_rocm_ver = max(_all_vers_main, key = lambda v: int(v)) except Exception as _e: _logging.getLogger(__name__).warning( "Windows ROCm: BNB DLL detection failed (%s); falling back to version '72'", _e, ) # rocm bnb DLL present, or HIP_PATH/ROCM_PATH set (DLL unparsable -> "72"). if _found_rocm_bnb or _hip_env: _bnb_rocm_ver_final = _bnb_rocm_ver or "72" os.environ["BNB_ROCM_VERSION"] = _bnb_rocm_ver_final _logging.getLogger(__name__).info( "Windows ROCm: set BNB_ROCM_VERSION=%s (from installed BNB wheel)", _bnb_rocm_ver_final, ) # Ensure backend dir is on sys.path so _platform_compat is importable when # main.py is launched directly (e.g. `uvicorn main:app`). _backend_dir = str(_Path(__file__).parent) if _backend_dir not in sys.path: sys.path.insert(0, _backend_dir) # `uvicorn main:app` bypasses run.py; seed thread caps here too. from utils.cpu_threads import configure_cpu_threads try: configure_cpu_threads() except ValueError as exc: _raw = os.environ.get("UNSLOTH_CPU_THREADS") raise SystemExit( f"Error: Invalid UNSLOTH_CPU_THREADS value {_raw!r}: {exc}" ) from None # Fix for Anaconda/conda-forge Python: seed platform._sys_version_cache before # any library imports that trigger attrs -> rich -> structlog -> platform crash. # See: https://github.com/python/cpython/issues/102396 import _platform_compat # noqa: F401 # Direct `uvicorn main:app` launches bypass run.py, so re-export here too # (mirrors run.py). Required BEFORE the unsloth-zoo import below, since # its LLAMA_CPP_DEFAULT_DIR binding is import-time. from utils.paths.storage_roots import studio_root as _studio_root try: _LEGACY_STUDIO_ROOT = (_Path.home() / ".unsloth" / "studio").resolve() except (OSError, ValueError): _LEGACY_STUDIO_ROOT = _Path.home() / ".unsloth" / "studio" try: _STUDIO_ROOT_RESOLVED = _studio_root().resolve() except (OSError, ValueError): _STUDIO_ROOT_RESOLVED = _studio_root() if _STUDIO_ROOT_RESOLVED != _LEGACY_STUDIO_ROOT: if not os.environ.get("UNSLOTH_STUDIO_HOME"): os.environ["UNSLOTH_STUDIO_HOME"] = str(_STUDIO_ROOT_RESOLVED) if not os.environ.get("UNSLOTH_LLAMA_CPP_PATH"): os.environ["UNSLOTH_LLAMA_CPP_PATH"] = str(_STUDIO_ROOT_RESOLVED / "llama.cpp") import hashlib import mimetypes import re as _re import shutil import warnings from contextlib import asynccontextmanager from importlib.metadata import PackageNotFoundError, version as package_version from typing import Optional from urllib.parse import urlparse _STUDIO_INSTALL_ID_RE = _re.compile(r"^[0-9a-f]{64}$") def _read_studio_install_id() -> str: """Per-install opaque id written by install.sh / install.ps1 at $STUDIO_HOME/share/studio_install_id. Returns "" when the file is absent (pre-PR install, fresh tree never run through the installer) or contains anything other than a 64-char lowercase-hex token -- in which case /api/health emits "" and the launcher's _check_health falls back to the existing "no baked id, accept any healthy Unsloth backend" path. This intentionally replaces a previous sha256(resolved_install_path) so the field carries no install-path information for callers reaching /api/health (relevant when Studio is run with -H 0.0.0.0).""" try: token = ( (_STUDIO_ROOT_RESOLVED / "share" / "studio_install_id").read_text().strip() ) except (OSError, ValueError): return "" return token if _STUDIO_INSTALL_ID_RE.fullmatch(token) else "" _STUDIO_ROOT_ID_CACHE: str = _read_studio_install_id() def _studio_root_id() -> str: """Same-install discriminator for /api/health: a per-install opaque token written once by the installer and read once at module import. Empty when no installer-written token is present; the launcher contract treats "" as "no baked id, accept any healthy backend".""" return _STUDIO_ROOT_ID_CACHE # Fix broken Windows registry MIME types. Some Windows installs map .js to # "text/plain" in the registry (HKCR\.js\Content Type). Python's mimetypes # module reads from the registry, and FastAPI/Starlette's StaticFiles uses # mimetypes.guess_type() to set Content-Type headers. Browsers enforce strict # MIME checking for ES module scripts (' html = html_bytes.decode("utf-8") html = html.replace("", f"{tag}", 1) return html.encode("utf-8"), nonce _DEFAULT_PORTS = {"http": 80, "https": 443, "ws": 80, "wss": 443} def _canonical_origin(scheme: str, netloc: str) -> Optional[tuple[str, str, int]]: """Canonicalise an Origin to ``(scheme, host, port)`` for equality. Browsers strip default ports (RFC 6454 sec 6.1) and scheme/host are case-insensitive (RFC 3986), so bare string compare misclassifies same-origin requests as cross-origin. Returns ``None`` on unparseable input so callers fall to the safer cross-origin default. """ scheme = (scheme or "").strip().lower() if not scheme or not netloc: return None # Strip userinfo (RFC 3986); Origin never carries credentials. if "@" in netloc: netloc = netloc.rsplit("@", 1)[1] # IPv6 hosts use brackets (RFC 3986 sec 3.2.2): ``[::1]:8902``. Bare # ``partition(":")`` mis-parses these and breaks ``unsloth studio -H ::1``. if netloc.startswith("["): close = netloc.find("]") if close == -1: return None host = netloc[1:close] rest = netloc[close + 1 :] if rest.startswith(":"): port_str = rest[1:] elif rest == "": port_str = "" else: return None else: host, _, port_str = netloc.partition(":") host = host.strip().lower() if not host: return None if port_str: try: port = int(port_str) except ValueError: return None else: port = _DEFAULT_PORTS.get(scheme, 0) return (scheme, host, port) def _is_same_origin_request(request: Request) -> bool: """True when Origin is missing or matches request's scheme://host:port. Top-level same-document GETs omit Origin, so missing counts as same-origin. Callers must also emit ``Vary: Origin``. Both sides are canonicalised via :func:`_canonical_origin` so default-port stripping and scheme/host case do not misclassify same-origin requests as cross-origin. """ origin = request.headers.get("origin") if origin is None: # Missing header: top-level same-document GETs omit Origin. return True # Empty string is not a valid serialised origin (RFC 6454 sec 6.1). if not origin: return False # "null" token (sandboxed iframes, file:// pages) is never same-origin. if origin == "null": return False # ``urlparse`` raises ``ValueError`` on malformed IPv6 brackets; swallow # so a garbage Origin doesn't 500 the SPA handler. try: parsed = urlparse(origin) except ValueError: return False origin_canon = _canonical_origin(parsed.scheme, parsed.netloc) if origin_canon is None: return False try: self_canon = _canonical_origin(request.url.scheme, request.url.netloc) except ValueError: return False if self_canon is None: return False return origin_canon == self_canon def setup_frontend(app: FastAPI, build_path: Path): """Mount frontend static files (optional)""" if not build_path.exists(): return False # Mount assets assets_dir = build_path / "assets" if assets_dir.exists(): app.mount("/assets", StaticFiles(directory = assets_dir), name = "assets") def _build_index_response(request: Request) -> Response: content = (build_path / "index.html").read_bytes() content = _strip_crossorigin(content) # Bootstrap pw is same-origin only; Vary: Origin keeps caches honest. if _is_same_origin_request(request): content, nonce = _inject_bootstrap(content, app) else: nonce = None headers = { "Cache-Control": "no-cache, no-store, must-revalidate", "Vary": "Origin", } if nonce: headers[_CSP_SCRIPT_NONCE_HEADER] = nonce return Response( content = content, media_type = "text/html", headers = headers, ) @app.get("/") async def serve_root(request: Request): return _build_index_response(request) @app.get("/{full_path:path}") async def serve_frontend(request: Request, full_path: str): if full_path in {"api", "v1"} or full_path.startswith(("api/", "v1/")): return {"error": "API endpoint not found"} file_path = (build_path / full_path).resolve() # Block path traversal — ensure resolved path stays inside build_path if not file_path.is_relative_to(build_path.resolve()): return Response(status_code = 403) if file_path.is_file(): return FileResponse(file_path) # Serve index.html as bytes — avoids Content-Length mismatch return _build_index_response(request) return True