# 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) # 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 _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 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() -> Response: content = (build_path / "index.html").read_bytes() content = _strip_crossorigin(content) content, nonce = _inject_bootstrap(content, app) headers = {"Cache-Control": "no-cache, no-store, must-revalidate"} if nonce: headers[_CSP_SCRIPT_NONCE_HEADER] = nonce return Response( content = content, media_type = "text/html", headers = headers, ) @app.get("/") async def serve_root(): return _build_index_response() @app.get("/{full_path:path}") async def serve_frontend(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() return True