# 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" # 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