unsloth/studio/backend/main.py
alkinun 8e69f2058e
Studio: add a setting to stop chats and Deep Research when the tab closes (#14062)
Closes #13950.

## Problem

The issue asks for two things: Deep Research and web search should keep
going after the tab closes, and there should be a toggle to choose
between that and stopping.

On `main`, the first is already true for local models. Chat replies, web
search and other server-run tools included, are durable runs (#10365),
and Deep Research runs live in the research supervisor. Both carry on
after the tab closes, and reopening the chat on any device reattaches.
What is missing is the choice: there is no way to make closing the tab
stop a run.

## Fix

**Setting.** Settings → Chat gains **Keep running after the tab
closes**. It defaults to **on**, so nothing changes unless someone turns
it off. It is stored with the other chat settings in
`/api/chat/settings`, so it follows the installation the way they do.

**Off.** When the setting is off, a new chat generation run or Deep
Research run is admitted with `stopWhenUnattended: true`. The flag is
only sent when it is set, so admission with the setting on is exactly
what it was before. A queued send keeps the value it was sent with, like
its other settings.

**Watchdog.** `state/unattended_runs.py` cancels a flagged run once no
tab has followed it for 30s (`UNSLOTH_STUDIO_UNATTENDED_RUN_GRACE_S`).
It cancels the way each run's own cancel route does. It decides by
attendance, the existing `run_subscribers` heartbeat, rather than by a
close or `pagehide` event:
- A phone that kills a background tab sends nothing at all, so a close
event can't be relied on.
- The grace lets a reload, or a follower reconnecting after a network
blip (its backoff tops out at 8s), pick the run back up. A second tab
still following the run keeps it going too.
- A run whose tab closes before its first event stream attaches still
stops: its grace counts from when it was admitted.

**Deep Research attendance.** Deep Research event streams now stamp
`run_subscribers` the way chat streams already do, under a `research:`
prefix because chat run ids come from the client. The flag is stored in
the research config, so a retry keeps it. A watch belongs to one attempt
(its `retry_count`), so a retry or rebind under the same id can't be
cancelled by the previous attempt's watch. After a restart, startup
re-arms every unsettled run that asked to stop, so a run whose tab never
comes back still stops.

**Unchanged.** Runs that never went durable still stop with their tab,
whatever the setting: temporary chats, replies from API models, turns
with attachments, and turns that use the browser panel. The setting's
hint says so.

## Testing

- **New backend tests:** `studio/backend/tests/test_unattended_runs.py`
(21 tests) covers:
  - the grace and its restart after a tab leaves;
- a second tab keeping a run going, and a settled run being dropped
without a cancel;
  - per-account scoping;
- a failing check, and a failed cancel that is retried on the next pass;
- a rebound or retried Deep Research run getting a fresh watch, or none
if it keeps running, and an obsolete watch never cancelling the attempt
that replaced it;
  - startup re-arming the Deep Research runs that asked to stop;
  - the research config flag;
- chat and Deep Research runs cancelled end to end against real
databases;
- the research event stream stamping and clearing attendance, and arming
the watch.
- **Settings payload:** `test_chat_settings_payload.py` gains a
round-trip test for the new setting.
- **Related backend suites:** the research, chat-generation,
tool-approval and account-settings suites pass, except 53 tests that
fail identically on a clean `main` checkout when those files run
together. Each of those files passes when run on its own.
- **Frontend:**
- `tests/keep-running-when-tab-closed.test.ts` covers the default, the
request fields, the settings round trip, both admissions sending the
choice, and a queued send keeping the choice it was sent with.
- `npm test`: 12,529 passed, 0 failed. `npm run typecheck` passes, and
`npm run i18n:check:strict` passes with the strings in every locale.
  - ESLint reports nothing on the lines this PR changes.
- **Lint:** Python is formatted with the pinned
`scripts/run_ruff_format.py` (ruff 0.6.9). `ruff check`,
`lint_duplicate_definitions`, `lint_backend_python_floor` and the other
repo lint scripts are clean.
- **Not tested in the app:** I have not run this in Studio or a browser.
No screenshots yet.
2026-10-18 18:01:13 -07:00

3341 lines
147 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import os
import sys
import threading
import time
from pathlib import Path as _Path
import asyncio
from dataclasses import asdict
from typing import Any, Optional
os.environ["PYTHONWARNINGS"] = "ignore"
# Pin GPU index ordering to PCI bus id before any torch import creates a CUDA context. Otherwise torch/CUDA
# default to FASTEST_FIRST while nvidia-smi (and Unsloth's VRAM probes) use PCI-bus order, so an index chosen
# from nvidia-smi can resolve to a different card. setdefault so an override wins; see utils/hardware/hardware.py.
os.environ.setdefault("CUDA_DEVICE_ORDER", "PCI_BUS_ID")
# Same ROCm AOTriton opt-in as unsloth/__init__.py, for a backend that defers importing torch;
# spawned workers inherit it. `setdefault` preserves an explicit override, including "0".
os.environ.setdefault("TORCH_ROCM_AOTRITON_ENABLE_EXPERIMENTAL", "1")
# The desktop app hands this process a GUI environment, and a GUI environment has
# no ~/.bashrc in it. `shell_path::fix_path()` in src-tauri spawns the
# login shell and then takes PATH out of it and nothing else, so an AMD host's
# HSA_OVERRIDE_GFX_VERSION / ROCM_PATH / USE_CK are dropped on the desktop path
# and kept on the `unsloth studio` one. #9926 is that difference: identical model
# and machine, SIGSEGV from the app and a clean run from a terminal.
#
# Here because HSA reads HSA_OVERRIDE_GFX_VERSION and USE_CK when torch first
# touches the GPU, which is far below this. A no-op unless the desktop app owns
# this process AND the host has an AMD GPU AND the variable is absent, so a
# terminal launch and every non-AMD host are unchanged.
_backend_dir = str(_Path(__file__).parent)
if _backend_dir not in sys.path:
sys.path.insert(0, _backend_dir)
try:
from utils.desktop_shell_env import import_rocm_env_from_login_shell as _import_rocm_env
_import_rocm_env()
except Exception:
pass
# normalize allocator booleans before torch parses them; spawned workers inherit the environment.
from utils.allocator_conf import normalize_allocator_conf as _normalize_allocator_conf
for _name, _old, _new in _normalize_allocator_conf():
print(
f"Unsloth: {_name}={_old!r} has noncanonical boolean casing, which PyTorch rejects; "
f"using {_new!r}.",
file = sys.stderr,
)
# Windows terminals default to the active system code page. Reconfigure stdout/stderr
# before the startup banner so non-ASCII output cannot crash the backend process.
if sys.platform == "win32":
for _win_stream in (sys.stdout, sys.stderr):
if _win_stream is not None and hasattr(_win_stream, "reconfigure"):
try:
_win_stream.reconfigure(encoding = "utf-8", errors = "replace")
except Exception:
pass
del _win_stream
_SYSTEM_GPU_CACHE_TTL_SECONDS = 10.0
_system_gpu_cache_lock = threading.Lock()
_system_gpu_cache: Optional[tuple[float, tuple[dict[str, Any], dict[str, Any]]]] = None
# Windows AMD ROCm DLL injection: Python 3.8+ ignores PATH for extension modules, so register ROCm bin
# dirs with os.add_dll_directory() to find amdhip64.dll etc. before any torch import.
if sys.platform == "win32":
# Module scope: the handle removes the search-path entry when garbage collected.
_ROCM_DLL_HANDLES: list = []
def _add_rocm_dll_dirs() -> None:
candidates = []
for _var in ("HIP_PATH", "ROCM_PATH"):
_val = os.environ.get(_var)
if _val:
candidates.append(os.path.join(_val, "bin"))
# 2. AMD installer: C:\Program Files\AMD\ROCm\<ver>\bin, 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: make hipInfo.exe resolvable for subprocess probes. bitsandbytes' get_rocm_gpu_arch()
# runs `hipinfo.exe` via PATH at import time; the AMD torch wheel ships it in the venv Scripts dir, which is
# on PATH only when the venv is activated, and Unsloth launches python directly. Gated on the file existing,
# so non-AMD hosts are untouched; subprocess PATH ignores DLL dirs.
_scripts_dir = os.path.dirname(sys.executable)
if os.path.isfile(os.path.join(_scripts_dir, "hipInfo.exe")):
import shutil as _shutil
if not _shutil.which("hipinfo.exe"):
os.environ["PATH"] = _scripts_dir + os.pathsep + os.environ.get("PATH", "")
del _shutil
del _scripts_dir
# Windows AMD ROCm: set BNB_ROCM_VERSION before any bitsandbytes import. bitsandbytes derives the
# rocm<ver>.dll name from torch.version.hip, but the wheel ships rocm72.dll, so the server crashes
# ("Configured ROCm binary not found") without this. Detect the shipped DLL (mirrors worker.py); gate on it
# rather than torch.version.hip to avoid importing torch.
if (
"BNB_ROCM_VERSION" not in os.environ
or os.environ.get("UNSLOTH_BNB_ROCM_VERSION_SOURCE") == "sitecustomize"
):
import glob as _glob
import logging as _logging
_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); leaving BNB_ROCM_VERSION as is",
_e,
)
# Only when a ROCm bnb DLL actually exists: HIP_PATH/ROCM_PATH alone (HIP SDK on a
# CUDA/CPU box) must not force a ROCm backend. Unparsable DLL name -> "72".
if _found_rocm_bnb:
_bnb_rocm_ver_final = _bnb_rocm_ver or os.environ.get("BNB_ROCM_VERSION") or "72"
os.environ["BNB_ROCM_VERSION"] = _bnb_rocm_ver_final
os.environ["UNSLOTH_BNB_ROCM_VERSION_SOURCE"] = "detected"
_logging.getLogger(__name__).info(
"Windows ROCm: set BNB_ROCM_VERSION=%s (from installed BNB wheel)",
_bnb_rocm_ver_final,
)
# Setting BNB_ROCM_VERSION makes bitsandbytes log a benign override notice; drop that record only.
if os.environ.get("BNB_ROCM_VERSION"):
import logging as _logging
_logging.getLogger("bitsandbytes.cextension").addFilter(
lambda _r: "environment variable detected" not in _r.getMessage()
)
# WSL AMD Strix Halo (gfx1151): in WSL the AMD GPU is reached via the ROCDXG bridge (librocdxg.so over
# /dev/dxg), which HSA loads only when HSA_ENABLE_DXG_DETECTION=1 is set BEFORE torch touches the GPU. A worker
# launched outside a login shell misses the installer's persisted env and falls back to CPU.
elif sys.platform.startswith("linux") and "HSA_ENABLE_DXG_DETECTION" not in os.environ:
try:
if os.path.exists("/dev/dxg") and any(
os.path.exists(os.path.join(_p, "librocdxg.so"))
for _p in ("/opt/rocm/lib", "/opt/rocm/lib64")
):
os.environ["HSA_ENABLE_DXG_DETECTION"] = "1"
import logging as _logging
_logging.getLogger(__name__).info(
"WSL ROCm: set HSA_ENABLE_DXG_DETECTION=1 (librocdxg bridge present)"
)
except Exception:
pass
# Backend dir on sys.path so _platform_compat imports under `uvicorn main:app`.
_backend_dir = str(_Path(__file__).parent)
if _backend_dir not in sys.path:
sys.path.insert(0, _backend_dir)
# OS trust store for TLS before anything opens a connection: behind a
# TLS-inspecting proxy certifi alone rejects every Hub request.
from utils.native_tls import activate_native_tls
from utils.happy_eyeballs import activate_happy_eyeballs
activate_native_tls()
activate_happy_eyeballs()
# `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
# Anaconda/conda-forge Python: seed platform._sys_version_cache before attrs -> rich ->
# structlog -> platform crashes. See https://github.com/python/cpython/issues/102396
import _platform_compat # noqa: F401
# Direct `uvicorn main:app` bypasses run.py, so re-export here too. Required BEFORE the
# unsloth-zoo import below, whose LLAMA_CPP_DEFAULT_DIR binding is import-time.
from utils.paths.storage_roots import studio_root as _studio_root
# Same reason, same deadline: unsloth_zoo.compiler reads UNSLOTH_COMPILE_LOCATION at import time, and
# without this a direct start falls back to a CWD-relative unsloth_compiled_cache.
from utils.paths.storage_roots import setup_cache_env as _setup_cache_env
try:
_setup_cache_env()
except Exception: # noqa: BLE001
pass
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()
from utils.paths.storage_roots import unsloth_home as _unsloth_home
_MASTER_ROOT = _unsloth_home()
# A master root pointed at the legacy path still owns runtimes beside it, so the equality alone
# would skip the export and leave unsloth_zoo on ~/.unsloth/llama.cpp.
if _STUDIO_ROOT_RESOLVED != _LEGACY_STUDIO_ROOT or _MASTER_ROOT is not None:
if not os.environ.get("UNSLOTH_STUDIO_HOME"):
os.environ["UNSLOTH_STUDIO_HOME"] = str(_STUDIO_ROOT_RESOLVED)
# Same derivation as run.py: the runtimes sit at the master root, beside studio/, and marking
# the wrong directory managed would make the bundled path look like an immutable override.
_MANAGED_ROOT = _MASTER_ROOT or _STUDIO_ROOT_RESOLVED
_MANAGED_LLAMA_CPP_PATH = _MANAGED_ROOT / "llama.cpp"
if not os.environ.get("UNSLOTH_LLAMA_CPP_PATH"):
os.environ["UNSLOTH_LLAMA_CPP_PATH"] = str(_MANAGED_LLAMA_CPP_PATH)
# A CLI/desktop launcher may already have exported Unsloth's own install path.
# Classify by the canonical value so that inherited default remains editable.
from utils.llama_cpp_path_settings import mark_managed_llama_cpp_path
mark_managed_llama_cpp_path(_MANAGED_LLAMA_CPP_PATH)
# huggingface_hub reads HF_ENDPOINT itself, at import, unnormalised and unvalidated.
from utils.hub_settings import apply_hub_settings as _apply_hub_settings
_apply_hub_settings()
del _apply_hub_settings
from utils.security.hub_data_files import install as _guard_hub_data_files
_guard_hub_data_files()
del _guard_hub_data_files
# The studio bundles unsloth_zoo; declare unsloth present (as `import unsloth` does) so its
# lazy submodule imports and the DiffusionGemma runner don't trip the install guard.
os.environ.setdefault("UNSLOTH_IS_PRESENT", "1")
# Same rule as unsloth/__init__.py, reached through Studio's own copy because this parent must
# not import unsloth: that runs unsloth/__init__.py, whose GPU branch pulls torch, Triton,
# transformers and the model stack into a long-lived process that exists to stay light, and can
# open a competing GPU context. Before anything imports transformers, which reads sentencepiece
# availability during its own import. UNSLOTH_DISABLE_SENTENCEPIECE=0 opts out.
from utils.sentencepiece_guard import disable_sentencepiece_on_windows as _no_sentencepiece
_no_sentencepiece()
del _no_sentencepiece
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 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 at $STUDIO_HOME/share/studio_install_id. Returns "" when absent or not a 64-char
lowercase-hex token; a launcher with a baked id rejects "", so it restores a missing id before starting
Studio. Carries no install-path info (matters when Unsloth runs -H 0.0.0.0)."""
try:
token = (
(_STUDIO_ROOT_RESOLVED / "share" / "studio_install_id")
.read_text(encoding = "utf-8")
.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 (cached at import). Empty when no installer token is
present."""
return _STUDIO_ROOT_ID_CACHE
# Some Windows installs map .js to text/plain, which mimetypes (hence StaticFiles) inherits
# and browsers reject for ES modules. add_type() before StaticFiles forces correct types.
if sys.platform == "win32":
mimetypes.add_type("application/javascript", ".js")
mimetypes.add_type("text/css", ".css")
if os.getenv("ENVIRONMENT_TYPE", "production") == "production":
warnings.filterwarnings("ignore")
from fastapi import Depends, FastAPI, HTTPException, Query, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, HTMLResponse, Response
from starlette.middleware.gzip import GZipMiddleware
from pathlib import Path
from datetime import datetime
from routes import (
auth_router,
chat_history_router,
data_recipe_router,
datasets_router,
export_router,
external_import_router,
inference_router,
inference_studio_router,
mcp_servers_router,
skills_router,
models_router,
providers_router,
openai_codex_auth_router,
rag_router,
research_runs_router,
chat_generation_runs_router,
training_history_router,
training_router,
video_router,
video_openai_router,
youtube_router,
)
import routes.browser as _browser_routes
from routes.llama import router as llama_router
from routes.engines import router as engines_router
from routes.llama_compat import is_engine_probe_path, router as llama_compat_router
from routes.whisper import router as whisper_router
from routes.npu import router as npu_router
from routes.preview import router as preview_router
from hub.routes import (
inventory_router as hub_inventory_router,
datasets_router as hub_datasets_router,
token_router as hub_token_router,
)
from picker.routes import templates_router as picker_templates_router
from hub.schemas.downloads import TransportCapabilities
from hub.utils.download_registry import (
get_download_transport_capabilities,
reap_orphan_workers as reap_hub_orphan_workers,
terminate_active_downloads as terminate_hub_downloads,
)
from routes.settings import router as settings_router
from routes.sandbox_capability import router as sandbox_capability_router
from routes.systemone import MCP_PATH as DECISIONS_MCP_PATH, RequireStudioAuth, decisions_mcp
from routes.systemone import router as systemone_router
from routes.prompts import router as prompts_router
from routes.library import router as library_router
from routes.profile_stats import router as profile_stats_router
from auth import policy as auth_policy, storage
from auth.authentication import authenticated_via_api_key, get_current_subject
from hub.utils.host_paths import redact_inventory_host_paths
from utils.hardware import (
start_background_detection,
get_device,
DeviceType,
get_backend_visible_gpu_info,
)
import utils.hardware.hardware as _hw_module
from utils.torch_warmup import (
DISABLE_ENV_VAR,
background_media_import,
join_background_warm,
prewarm_diffusers_if_image_models_exist,
reset_background_warm,
start_background_warm,
warm_status,
)
from utils.cache_cleanup import (
clear_compiled_cache_unless_shared as _clear_compiled_cache_unless_shared,
)
from utils.lifespan_shutdown import run_lifespan_shutdown
from utils.native_path_leases import native_path_leases_supported
from utils.client_ip import client_ip
from utils.hf_endpoint import (
DEFAULTS_BY_HEALTH_KEY as _HF_ENDPOINT_DEFAULTS,
endpoint_is_reachable_by as _endpoint_is_reachable_by,
browser_hf_endpoint,
csp_asset_sources,
csp_connect_sources,
get_hf_datasets_server,
)
from hub import endpoint_proxy as _hub_endpoint_proxy
from hub.modelscope.router import (
BROWSER_PREFIX as _MODELSCOPE_BROWSER_PREFIX,
build_router as _build_modelscope_router,
)
from utils.hub_settings import active_source as _active_hub_source
from utils.update_status import (
get_studio_install_source_status,
get_studio_update_status,
)
from utils.release_notes import get_release_notes, is_supported_version_query
from utils.studio_version import get_studio_version
from utils.api_errors import install_api_error_handlers
def get_unsloth_version() -> str:
try:
return package_version("unsloth")
except PackageNotFoundError:
pass
# Both files: the literal moved to _version.py, and models/_utils.py now holds only a re-export, which
# this prefix scan does not match. Trying both keeps a half-updated tree reporting a real version.
root = _Path(__file__).resolve().parents[2] / "unsloth"
for version_file in (root / "_version.py", root / "models" / "_utils.py"):
try:
for line in version_file.read_text(encoding = "utf-8").splitlines():
if line.startswith("__version__ = "):
return line.split("=", 1)[1].strip().strip('"').strip("'")
except (OSError, UnicodeDecodeError):
continue
return "dev"
UNSLOTH_VERSION = get_unsloth_version()
STUDIO_VERSION = get_studio_version()
def _load_desktop_owner() -> dict[str, str] | None:
token = os.environ.pop("UNSLOTH_STUDIO_DESKTOP_OWNER_TOKEN", "")
kind = os.environ.pop("UNSLOTH_STUDIO_DESKTOP_OWNER_KIND", "")
if kind != "tauri" or not token:
return None
return {
"kind": "tauri",
"token_sha256": hashlib.sha256(token.encode("utf-8")).hexdigest(),
}
_DESKTOP_OWNER = _load_desktop_owner()
# The Tauri desktop app runs the backend locally, so stdio MCP servers are safe ("0" opts out). Tracked as an
# automatic loopback default so publishing a runtime tunnel can suspend it without overriding an explicit
# operator choice.
if _DESKTOP_OWNER:
from utils.host_policy import apply_stdio_mcp_loopback_default as _apply_desktop_stdio_default
_apply_desktop_stdio_default("127.0.0.1")
del _apply_desktop_stdio_default
def _desktop_owner() -> dict[str, str] | None:
return _DESKTOP_OWNER
def _start_helper_precache_if_enabled() -> None:
"""Start optional Helper LLM GGUF pre-cache only after explicit opt-in."""
try:
from utils.helper_precache_settings import should_preload_helper_on_startup
if not should_preload_helper_on_startup():
return
except Exception:
return
import threading
def _precache():
try:
from utils.datasets.llm_assist import precache_helper_gguf
precache_helper_gguf()
except Exception:
pass # non-critical
threading.Thread(target = _precache, daemon = True, name = "helper-gguf-precache").start()
def _run_llama_cpp_startup_probes(app: FastAPI) -> None:
"""llama.cpp capability (MTP support) + freshness (release age) probes, run OFF the startup critical path.
Both are cached and freshness has a 24h disk TTL, but on a cold/expired cache the freshness check makes
a blocking GitHub request, and on macOS the first `llama-server --help` exec can stall on Gatekeeper
verification, and neither must gate `Application startup complete`. Writes app.state only; nothing reads
those values synchronously at startup."""
try:
from core.inference.llama_cpp import LlamaCppBackend
from utils.llama_cpp_freshness import (
check_prebuilt_freshness,
format_stale_warning,
)
_bin = LlamaCppBackend._find_llama_server_binary()
_caps = LlamaCppBackend.probe_server_capabilities(_bin)
app.state.llama_cpp_capabilities = _caps
_freshness = check_prebuilt_freshness(_bin)
app.state.llama_cpp_freshness = _freshness
import structlog as _structlog
_log = _structlog.get_logger(__name__)
if (
_caps.get("found")
and not _caps.get("supports_mtp")
and not _caps.get("mtp_probe_inconclusive")
):
_msg = (
"llama.cpp prebuilt lacks MTP support "
"(--spec-type mtp/draft-mtp). Run `unsloth studio update`. "
"MTP GGUFs will load without speculative decoding."
)
_log.warning(_msg)
print(f"WARNING: {_msg}", flush = True)
if _freshness.get("stale"):
_msg = format_stale_warning(_freshness)
_log.warning(_msg)
print(f"WARNING: {_msg}", flush = True)
except Exception as _probe_exc:
import structlog as _structlog
_structlog.get_logger(__name__).debug("llama.cpp startup probes failed: %s", _probe_exc)
def _start_llama_cpp_probes_if_enabled(app: FastAPI) -> None:
"""Run the llama.cpp startup probes on a daemon thread, off the startup critical path. Skipped entirely
when update checks are disabled, so a fully offline boot makes no background network calls."""
if os.environ.get("UNSLOTH_DISABLE_UPDATE_CHECK") == "1":
return
from utils.audio_cpp_update import start_model_specs_backfill
start_model_specs_backfill()
threading.Thread(
target = _run_llama_cpp_startup_probes,
args = (app,),
daemon = True,
name = "llama-cpp-startup-probe",
).start()
_post_warm_thread: Optional[threading.Thread] = None
_post_warm_lock = threading.Lock()
# Bumped by every start and stop. A worker captures the value it started with and stops once
# it no longer matches, so one parked in join_background_warm() cannot act after shutdown.
_post_warm_generation = 0
def _post_warm_current_generation() -> int:
with _post_warm_lock:
return _post_warm_generation
def _start_post_warm_thread() -> bool:
"""Put up a post-warm worker for this lifespan. True iff one was started. Starts one even while a previous
worker is parked in the warm join: declining there left a restart with no worker at all, since the old
one was alive so this returned early, then read the shutdown and exited. Generations make the overlap
safe."""
global _post_warm_thread, _post_warm_generation
with _post_warm_lock:
_post_warm_generation += 1
mine = _post_warm_generation
thread = threading.Thread(
target = _post_warm_background_work,
args = (mine,),
daemon = True,
name = f"post-warm-{mine}",
)
_post_warm_thread = thread
thread.start()
return True
def _stop_post_warm_thread() -> None:
"""Retire whatever worker is current; never wait for it. Joining would hold shutdown for the rest of the ML
stack import, the stall this path exists to avoid; bumping the generation suffices, since the worker
re-reads it after its join."""
global _post_warm_generation
with _post_warm_lock:
_post_warm_generation += 1
def _post_warm_retired(generation: Optional[int]) -> bool:
"""True when this post-warm worker's lifespan has ended (logs once when it has). The remaining work imports
optional platform or RAG scheduling modules, so none of it may start for a stopped lifespan."""
if generation is None or _post_warm_current_generation() == generation:
return False
import structlog as _structlog
_structlog.get_logger(__name__).info(
"post-warm work %s stood down: its lifespan ended while the ML stack was still loading",
generation,
)
return True
def _start_linked_folder_auto_sync(generation: Optional[int]) -> None:
# A real lifespan worker carries a generation; direct calls without one are tests.
if generation is None:
return
try:
from core.rag.folder_sync import start_auto_sync
from storage.studio_db import get_chat_project
start_auto_sync(
admission_lock = _post_warm_lock,
admit = lambda: _post_warm_generation == generation,
project_exists = lambda project_id: get_chat_project(project_id) is not None,
)
except Exception as exc:
import structlog as _structlog
_structlog.get_logger(__name__).warning(
"linked-folder auto-sync failed at startup: %s", exc
)
def _post_warm_background_work(generation: Optional[int] = None) -> None:
"""Platform repair and linked-folder lifecycle work after the coordinated warm. MLX repair used to probe the
runtime before the socket bound; joining first keeps that optional probe out of the login-screen
critical path. Linked-folder startup only loads embeddings when a queued sync has real ingestion
work."""
# No-op when the warm never started, so this is safe under the kill switch.
join_background_warm()
# Shutdown routinely lands while parked in the join above, and everything below imports or loads part
# of the stack. Rechecked before every action; generation is None only in tests.
if _post_warm_retired(generation):
return
# Apple Silicon with MLX missing => chat-only; reinstall mlx and re-detect so a dropped mlx
# self-heals. Opt out with UNSLOTH_DISABLE_MLX_AUTOREPAIR=1; after the warm, the probe imports MLX.
try:
from utils.mlx_repair import start_mlx_autorepair_if_needed
if _post_warm_retired(generation):
return
start_mlx_autorepair_if_needed()
except Exception as _mlx_exc:
import structlog as _structlog
# Warning, not debug: this decides the MLX verdict on every healthy Apple Silicon boot, so a half-applied
# update (new mlx_repair.py over older hardware.py) arrives here and would silently leave Train/Export
# greyed out for the session.
_structlog.get_logger(__name__).warning("mlx autorepair skipped: %s", _mlx_exc)
if _post_warm_retired(generation):
return
# Off the polled path on purpose: /api/system must not import torchao itself (see
# _dense_quant_supported). Gated on torch being up rather than assumed, since
# UNSLOTH_STUDIO_DISABLE_TORCH_WARM=1 exists precisely to keep the ML stack cold.
if "torch" in sys.modules:
# Inside the media import window: skipped once a load claimed it; /api/system resolves both later.
with background_media_import() as _window_open:
if _window_open:
try:
_refresh_dense_quant_capability()
except Exception as _dq_exc: # noqa: BLE001 -- a picker label must never break the warm
import structlog as _structlog
_structlog.get_logger(__name__).debug(
"dense quant capability skipped: %s", _dq_exc
)
try:
_refresh_quantised_streaming_capability()
except Exception as _qs_exc: # noqa: BLE001 -- a picker tier must never break the warm
import structlog as _structlog
_structlog.get_logger(__name__).debug(
"quantised streaming capability skipped: %s", _qs_exc
)
if _post_warm_retired(generation):
return
_start_linked_folder_auto_sync(generation)
try:
from core import chat_originals
from core.training.account_jobs import startup_reconciliation_accounts
from utils.account_context import run_as
for account in startup_reconciliation_accounts():
if _post_warm_retired(generation):
return
run_as(account, chat_originals.sweep, True)
except Exception: # noqa: BLE001
pass
# Last, and deliberately so: it is the only item here that is pure latency work rather than
# correctness, so everything above keeps its place in the queue. Roughly 5.3s of diffusers
# import that the first image load would otherwise pay, moved onto this thread, and only on
# installs that actually have an image or video model. Self-guarded and never fatal.
if _post_warm_retired(generation):
return
try:
prewarm_diffusers_if_image_models_exist()
except Exception as _prewarm_exc: # noqa: BLE001 -- latency work must never end the worker
import structlog as _structlog
_structlog.get_logger(__name__).debug("diffusers prewarm skipped: %s", _prewarm_exc)
def clear_compiled_cache_unless_shared(app: FastAPI) -> None:
"""Clear the compiled cache unless a sibling backend of this install is live. The decision lives in
cache_cleanup, next to the paths it clears and the lock that serializes it against a sibling's startup;
run_server puts the probe on app.state because main.py must not import run.py back."""
_clear_compiled_cache_unless_shared(getattr(app.state, "live_sibling_backend", None))
def banner_autofill_available(app_state, environ) -> bool:
"""Whether _inject_bootstrap will hand the login page the credential.
Read from the launch, not the environment: run_server sets UNSLOTH_API_ONLY and never
clears it, and an embedded host may call run_server() again in the same process with
different flags, so the variable outlives the launch that set it. The environment is
only the fallback for a direct uvicorn launch that never went through run_server.
"""
if getattr(app_state, "suppress_bootstrap_injection", False):
return False
api_only = getattr(app_state, "api_only", None)
if api_only is None:
api_only = environ.get("UNSLOTH_API_ONLY") == "1"
# Desktop-owned api-only still serves the UI (and the autofill) to a loopback browser.
return not api_only or _desktop_owner() is not None
def bootstrap_banner_lines(
username: str, bootstrap_path, password: Optional[str], *, autofill_available: bool
) -> "list[str]":
"""The first-boot banner for a freshly created admin account.
Printing the password is the exception, not the rule: _inject_bootstrap fills the
login form in, so a launch that gets the injection must keep the credential out of
a log that ends up in a bug report.
"""
lines = ["=" * 60, "DEFAULT ADMIN ACCOUNT CREATED", f" username: {username}"]
if autofill_available or not password:
lines.append(f" password saved to: {bootstrap_path}")
else:
lines.append(f" password: {password}")
lines.append(f" also saved to: {bootstrap_path}")
lines.append(" Open the Unsloth UI to sign in and change it.")
lines.append("=" * 60)
return lines
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Startup: detect hardware, seed default admin if needed. Shutdown: clean up compiled cache."""
import time as _time
_lifespan_started = _time.perf_counter()
import structlog as _structlog
_lifespan_log = _structlog.get_logger(__name__)
clear_compiled_cache_unless_shared(app)
# Here because both launch paths reach it after the frontend decision: run.py calls setup_frontend(), and
# `uvicorn main:app` bypasses run.py entirely. With no catch-all the engine paths match on method alone and
# answer 405, read as "exists".
if not getattr(app.state, "frontend_mounted", False):
try:
from routes.llama_compat import add_get_denials
add_get_denials(app)
except Exception: # noqa: BLE001 -- never block startup over a discovery route
_lifespan_log.warning("could not install API-only probe denials", exc_info = True)
# Move the legacy sandbox up here rather than from the first request: the copy can be minutes when the
# studio home is on another filesystem.
try:
from core.inference.tools import (
migrate_legacy_sandbox_in_background,
start_sandbox_recovery,
)
migrate_legacy_sandbox_in_background()
# A tree renamed for deletion by a run that was killed, and the workspace deletes it left pending:
# both waited for the next Python or terminal call, which ordinary chat never makes.
start_sandbox_recovery()
except Exception: # noqa: BLE001
pass
# Warm the OS sandbox probe so the "off" gate has an answer.
try:
from core.inference.os_sandbox import start_tool_isolation_warmup
start_tool_isolation_warmup()
except Exception: # noqa: BLE001 -- the first tool call probes instead
_lifespan_log.warning("could not start the sandbox warm-up", exc_info = True)
# Windows AMD driver with the ROCm/TheRock#7221 idle-eviction bug: one log line, plus gpu.driver_warning.
try:
from utils.hardware.hardware import start_amd_driver_check
start_amd_driver_check()
except Exception: # noqa: BLE001 -- a warning, never a startup failure
_lifespan_log.debug("could not start the AMD driver check", exc_info = True)
try:
from hub.services.models.account_access import adopt_unnamed_public_proofs
from utils.hub_settings import operator_hf_endpoint
adopt_unnamed_public_proofs(operator_hf_endpoint())
except Exception: # noqa: BLE001 -- unnamed proofs are then only ignored
_lifespan_log.warning("could not name recorded public-repo proofs", exc_info = True)
# Remove stale .venv_overlay from old versions; switching now uses .venv_t5/.
overlay_dir = Path(__file__).resolve().parent.parent.parent / ".venv_overlay"
if overlay_dir.is_dir():
shutil.rmtree(overlay_dir, ignore_errors = True)
# Hardware detection and MLX autorepair moved out of this lifespan: both import heavy
# runtimes and uvicorn binds only once this returns, so they held the login screen.
# Before the first writer, so startup's own connections stop checkpointing too.
try:
from storage.studio_db import open_wal_keeper
open_wal_keeper()
except Exception as exc:
_lifespan_log.warning("studio.db WAL keeper failed at startup: %s", exc)
from utils.account_context import OWNER as _owner_account, run_as as _run_as
try:
from core.training.account_jobs import startup_reconciliation_accounts
_reconcile_accounts = startup_reconciliation_accounts()
except Exception as exc:
_lifespan_log.warning("could not enumerate accounts to reconcile: %s", exc)
_reconcile_accounts = [_owner_account]
for _account in _reconcile_accounts:
try:
from storage.studio_db import cleanup_orphaned_runs
_run_as(_account, cleanup_orphaned_runs)
except Exception as exc:
_lifespan_log.warning("cleanup_orphaned_runs failed at startup: %s", exc)
try:
from storage.chat_generation_runs_db import reconcile_orphaned_runs
reconciled_chat_runs = _run_as(_account, reconcile_orphaned_runs)
if reconciled_chat_runs:
_lifespan_log.warning(
"Marked %s interrupted chat generation run(s) failed after restart.",
reconciled_chat_runs,
)
except Exception as exc:
_lifespan_log.warning("chat generation orphan reconciliation failed: %s", exc)
# Each account has its own rag.db, so its stuck ingestion jobs are only visible from inside it.
try:
from storage.rag_db import reconcile_orphaned_ingestion_jobs
_run_as(_account, reconcile_orphaned_ingestion_jobs)
except Exception as exc:
_lifespan_log.warning("reconcile_orphaned_ingestion_jobs failed at startup: %s", exc)
try:
from hub.services.models.local_inventory import register_launch_scan_folders
_run_as(_owner_account, register_launch_scan_folders)
except Exception as exc:
_lifespan_log.warning("--models-dir registration failed at startup: %s", exc)
try:
# The boot pass above only settles runs orphaned by the previous process. A run that wedges while this one
# keeps serving needs the same reconciliation on an interval, bounded to runs whose progress lease has
# expired.
from core.inference.chat_generation_runs import start_lease_sweeper
start_lease_sweeper(app)
except Exception as exc:
_lifespan_log.warning("chat generation lease sweeper failed to start: %s", exc)
# Stops the runs whose owner asked for them to end with the tab, once no tab follows them.
from state import unattended_runs as _unattended_runs
_unattended_runs.start()
reap_hub_orphan_workers()
try:
from hub.utils.download_manifest import migrate_ordinary_v2_manifests_for_downgrade
migrated_manifests = migrate_ordinary_v2_manifests_for_downgrade()
if migrated_manifests:
_lifespan_log.info(
"Migrated %s Hub download manifest(s) for downgrade compatibility.",
migrated_manifests,
)
except Exception as exc:
_lifespan_log.warning("Hub manifest compatibility migration failed: %s", exc)
# llama.cpp probes: capability (MTP support) + freshness (release age). Inline they could block `Application
# startup complete` for tens of seconds on macOS (cold GitHub cache, Gatekeeper verifying the unsigned
# binary). Nothing reads them synchronously at startup.
app.state.llama_cpp_capabilities = None
app.state.llama_cpp_freshness = None
_start_llama_cpp_probes_if_enabled(app)
# Embeddings stay cold until ingestion or retrieval actually requests vectors.
_start_helper_precache_if_enabled()
from core.inference.audio_inputs import start_sweeper as _start_audio_input_sweeper
_start_audio_input_sweeper()
from core.research_runs import ResearchSupervisor
app.state.research_supervisor = ResearchSupervisor(app)
app.state.research_supervisor.start()
try:
# The supervisor resumes runs that asked to stop with their tab; their watches died with the old process.
from routes.research_runs import rearm_unattended_research_runs
rearm_unattended_research_runs(app)
except Exception as exc:
_lifespan_log.warning("re-arming unattended research runs failed: %s", exc)
from core.inference.chat_generation_runs import ChatGenerationSupervisor
app.state.chat_generation_supervisor = ChatGenerationSupervisor(app)
# Idle auto-unload loop (no-op unless the OpenAI auto-unload TTL is set).
from core.inference.llama_keepwarm import idle_unload_loop, sweep_slot_save_dir
sweep_slot_save_dir()
app.state.idle_unload_task = asyncio.create_task(idle_unload_loop())
# Initialize RSA key pair for API key encryption (external providers).
from core.inference.key_exchange import init_key_pair
init_key_pair()
# Stall thread-dump watchdog (#9712), only with UNSLOTH_STUDIO_STALL_WATCHDOG=1.
from utils.stall_watchdog import stand_down_for_the_warm, start_stall_watchdog
start_stall_watchdog(asyncio.get_running_loop(), suppress = stand_down_for_the_warm)
_lifespan_log.info(
"lifespan pre-auth setup completed in %.1fms",
(_time.perf_counter() - _lifespan_started) * 1000,
)
# run_server's pre-bind gate sets suppress_bootstrap_injection when a public URL is about
# to serve with the default credential: never capture the bootstrap password into app.state.
_suppress_bootstrap = getattr(app.state, "suppress_bootstrap_injection", False)
_created = storage.ensure_default_admin()
app.state.bootstrap_password = None if _suppress_bootstrap else storage.get_bootstrap_password()
# A tunnel launch runs the pre-bind gate first and that gate seeds the account, so
# _created is False there and the whole banner would be skipped on exactly the launch
# that needs it. requires_password_change: the gate may also have taken a new password
# at its prompt, which retires the bootstrap one.
if (_created or storage.admin_created_this_process()) and storage.requires_password_change(
storage.DEFAULT_ADMIN_USERNAME
):
bootstrap_path = storage.DB_PATH.parent / ".bootstrap_password"
_autofill = banner_autofill_available(app.state, os.environ)
print(
"\n"
+ "\n".join(
bootstrap_banner_lines(
storage.DEFAULT_ADMIN_USERNAME,
bootstrap_path,
storage.get_bootstrap_password(),
autofill_available = _autofill,
)
)
+ "\n"
)
# Last, so it never contends for the GIL: the socket binds as soon as this returns, so the login
# screen is up while torch/transformers/datasets load.
start_background_warm()
_start_post_warm_thread()
_lifespan_log.info(
"lifespan startup completed in %.1fms",
(_time.perf_counter() - _lifespan_started) * 1000,
)
# Persist only terminal scalar usage from authenticated third-party API requests. The monitor stays
# storage-agnostic until the production lifespan is ready, which keeps imports and unit tests deterministic.
from core.inference.api_monitor import api_monitor as _api_monitor
from storage.api_usage_db import (
acquire_api_usage_writer as _acquire_api_usage_writer,
enqueue_api_usage as _enqueue_api_usage,
release_api_usage_writer as _release_api_usage_writer,
)
_api_usage_writer_lease = _acquire_api_usage_writer()
_api_usage_callback_lease = _api_monitor.acquire_terminal_callback(_enqueue_api_usage)
yield
# Remove only this lifespan's callback. A concurrently live sibling keeps both the monitor sink and the
# shared serialized writer; the final owner drains accepted receipts off the event loop before stopping the
# worker.
_api_monitor.release_terminal_callback(_api_usage_callback_lease)
# Before any shutdown await: a warm finishing during one would still read the lifespan as current.
_stop_post_warm_thread()
# Before teardown blocks the loop, or shutdown dumps as a stall.
from utils.stall_watchdog import stop_stall_watchdog
stop_stall_watchdog()
# Retire the coordinated warm at shutdown entry too. run_lifespan_shutdown() repeats this after
# cleanup, but its awaits would otherwise let startup imports continue for a stopped lifespan.
_invalidate_detection = getattr(_hw_module, "invalidate_detection", None)
if _invalidate_detection is not None:
_invalidate_detection()
await asyncio.to_thread(_release_api_usage_writer, _api_usage_writer_lease)
from core.inference.openai_codex_auth import shutdown_flows
await shutdown_flows()
try:
from core.rag.folder_sync import stop_auto_sync
stop_auto_sync()
except Exception as exc:
_lifespan_log.warning("linked-folder auto-sync failed at shutdown: %s", exc)
_idle_task = getattr(app.state, "idle_unload_task", None)
if _idle_task is not None:
_idle_task.cancel()
try:
await _idle_task
except asyncio.CancelledError:
pass
from state import unattended_runs as _unattended_runs
await _unattended_runs.stop()
_research_supervisor = getattr(app.state, "research_supervisor", None)
if _research_supervisor is not None:
await _research_supervisor.stop()
_chat_generation_supervisor = getattr(app.state, "chat_generation_supervisor", None)
if _chat_generation_supervisor is not None:
await _chat_generation_supervisor.stop()
from core.inference.llama_http import aclose as _close_llama_http
await _close_llama_http()
from core.systemone.laya_runtime import shutdown as shutdown_decisions
await asyncio.to_thread(shutdown_decisions)
await run_lifespan_shutdown(
terminate_hub_downloads,
lambda: clear_compiled_cache_unless_shared(app),
_hw_module,
)
# Shutdown cleared the state this warm produced, so release the one-per-process latch.
reset_background_warm()
# Last, so every other shutdown step has had its final write first.
from storage.studio_db import close_wal_keeper
close_wal_keeper()
app = FastAPI(
title = "Unsloth UI Backend",
version = UNSLOTH_VERSION,
description = "Backend API for Unsloth UI - Training and Model Management",
lifespan = lifespan,
# Swagger UI and ReDoc are re-registered below on these same paths, against vendored assets instead of
# a CDN: FastAPI's built-ins point at cdn.jsdelivr.net, and this origin holds the auth tokens.
docs_url = None,
redoc_url = None,
swagger_ui_oauth2_redirect_url = None,
)
app.state.secure = os.environ.get("UNSLOTH_SECURE") == "1"
from fastmcp.utilities.lifespan import combine_lifespans # noqa: E402
# Mounted ahead of /mcp, which would otherwise swallow this path.
_decisions_mcp_app = decisions_mcp.http_app(path = "/", stateless_http = True, json_response = True)
app.router.lifespan_context = combine_lifespans(lifespan, _decisions_mcp_app.lifespan)
app.mount(DECISIONS_MCP_PATH, RequireStudioAuth(_decisions_mcp_app))
# Always mounted; the gate answers 404 until the owner turns agent access on.
from mcp_server import create_studio_mcp # noqa: E402
from studio_mcp.gate import StudioMcpGate # noqa: E402
_studio_mcp_app = create_studio_mcp().http_app(path = "/", stateless_http = True)
app.router.lifespan_context = combine_lifespans(
app.router.lifespan_context, _studio_mcp_app.lifespan
)
app.mount("/mcp", StudioMcpGate(_studio_mcp_app))
from loggers.config import LogConfig
from loggers.handlers import LoggingMiddleware
logger = LogConfig.setup_logging(
service_name = "unsloth-studio-backend",
env = os.getenv("ENVIRONMENT_TYPE", "production"),
)
app.add_middleware(LoggingMiddleware)
if os.environ.get("UNSLOTH_STUDIO_MCP_TOKEN", "").strip():
logger.warning(
"UNSLOTH_STUDIO_MCP_TOKEN is no longer used and /mcp refuses it. "
"Agents connect with an Unsloth Studio API key (sk-unsloth-…) from Settings > API."
)
class ResearchPortMiddleware:
"""Capture the bound port without replacing the ASGI receive channel."""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] == "http":
request_app = scope.get("app")
supervisor = getattr(getattr(request_app, "state", None), "research_supervisor", None)
if supervisor is not None:
supervisor.note_server_address(scope.get("server"))
await self.app(scope, receive, send)
app.add_middleware(ResearchPortMiddleware)
# img/media-src allow any https origin so HF model-card assets render (mirrors
# tauri.conf.json); scripts/frames/connect-src stay same-origin + HF.
from starlette.datastructures import MutableHeaders # noqa: E402
_CSP_SCRIPT_NONCE_HEADER = "x-internal-script-nonce"
_ARTIFACT_PREVIEW_FRAME_PATH = "/api/inference/artifact-preview-frame"
# Framed shells: their own CSP frame-ancestors governs embedding, so no X-Frame-Options DENY.
_FRAME_SHELL_PATHS = frozenset(
{
_ARTIFACT_PREVIEW_FRAME_PATH,
"/api/inference/mcp-app-frame",
_browser_routes.BROWSER_FRAME_PATH,
_browser_routes.BROWSER_PRINT_PATH,
}
)
_DOCS_FONT_CSS = "https://fonts.googleapis.com"
_DOCS_FONT_FILES = "https://fonts.gstatic.com"
_DOCS_PATHS = frozenset({"/docs", "/docs/oauth2-redirect", "/redoc"})
_DOCS_ASSETS_URL = "/docs-assets"
_DOCS_ASSETS_DIR = Path(__file__).parent / "assets" / "docs_ui"
# /content is Colab's working directory -- more reliable than env vars.
import importlib.util as _importlib_util
_IS_COLAB = os.path.isdir("/content") and (
bool(os.environ.get("COLAB_BACKEND_URL"))
or bool(os.environ.get("COLAB_JUPYTER_IP"))
or _importlib_util.find_spec("google.colab") is not None
)
def _reportable_hf_endpoints(request) -> dict:
"""The endpoints to hand the browser, which are not always the ones we use.
A loopback endpoint names a proxy on the MACHINE THE BACKEND RUNS ON, and a
private-network one an address on the backend's LAN. Handing either to a
browser elsewhere makes it fetch its OWN localhost or its OWN 10.0.0.5: the
calls either fail, or hit an unrelated service that, if it answers the CORS
preflight, is handed the user's Hub bearer token. endpoint_is_reachable_by
holds the rule; the backend keeps using its own value either way.
The client comes from client_ip(), not the socket peer: through the managed
Cloudflare tunnel the peer IS loopback, being the local cloudflared process
rather than the visitor, and an address it cannot determine reads as remote.
"""
from utils.hub_settings import saved_only_endpoints
# The owner-only settings route guards a saved endpoint; the browser reaches it through the relay.
hidden = saved_only_endpoints()
reported = {}
for key, value in (
("hf_endpoint", browser_hf_endpoint()),
("hf_datasets_server", get_hf_datasets_server()),
):
if value not in hidden and _endpoint_is_reachable_by(value, client_ip(request)):
reported[key] = value
else:
reported[key] = _HF_ENDPOINT_DEFAULTS[key]
return reported
def _build_csp(script_nonce: "str | None" = None, *, docs: bool = False) -> str:
script_src = "script-src 'self'"
style_src = "style-src 'self' 'unsafe-inline'"
worker_src = "worker-src 'self'"
font_src = "font-src 'self' data:"
if docs:
# script-src is deliberately untouched: the docs bundles are served from this origin and their inline init
# runs off the nonce. ReDoc's Google Fonts sheet pulls faces from gstatic, and its search index runs in a
# worker it builds from a blob.
style_src += f" {_DOCS_FONT_CSS}"
font_src += f" {_DOCS_FONT_FILES}"
worker_src += " blob:"
if script_nonce:
script_src += f" 'nonce-{script_nonce}'"
# Colab parent frames span multi-level *.prod.colab.dev subdomains (CSP wildcards match
# one level) and null-origin iframes; '*' is safe as Colab is a sandboxed single user.
frame_ancestors = "*" if _IS_COLAB else "'none'"
# A mirror has to reach connect-src, or the browser blocks the Hub calls routed
# there. img/media carry a bare https:, so only a loopback HTTP one needs those.
# Origins only: a host-source with a path is matched exactly unless it ends
# in "/", so a path-prefixed mirror would block every request under it.
hf_connect_src = " ".join(
dict.fromkeys(
(
"https://huggingface.co",
"https://datasets-server.huggingface.co",
*csp_connect_sources(),
)
)
)
asset_sources = csp_asset_sources()
hf_asset_src = (" " + " ".join(asset_sources)) if asset_sources else ""
# In Colab the kernel scaffolding injects scripts and fetch/WS from *.prod.colab.dev and
# *.googleusercontent.com, so widen script-src/connect-src. Scripts still use a nonce.
if _IS_COLAB:
script_src += " https://*.prod.colab.dev https://*.googleusercontent.com"
connect_src = (
f"'self' blob: data: {hf_connect_src} "
"https://*.prod.colab.dev wss://*.prod.colab.dev "
"https://*.googleusercontent.com wss://*.googleusercontent.com"
)
else:
connect_src = f"'self' {hf_connect_src}"
return (
"default-src 'self'; "
f"img-src 'self' data: blob: https:{hf_asset_src}; "
f"media-src 'self' data: blob: https:{hf_asset_src}; "
f"connect-src {connect_src}; "
f"{style_src}; "
f"{script_src}; "
f"{worker_src}; "
f"{font_src}; "
"frame-src 'self'; "
f"frame-ancestors {frame_ancestors}; "
"form-action 'self'; "
"base-uri 'self'"
)
# Any of these means the response already says how a browser may cache or revalidate it.
_CACHE_POLICY_HEADERS = ("cache-control", "expires", "etag", "last-modified")
class SecurityHeadersMiddleware:
"""Set baseline security headers; splice per-response inline-script nonces into CSP. Pure ASGI (not
BaseHTTPMiddleware) so streaming responses are not wrapped in an anyio stream."""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
path = scope.get("path", "")
async def send_wrapper(message):
if message["type"] == "http.response.start":
# ASGI headers are an iterable; coerce to a list so MutableHeaders can mutate in place.
raw = message.setdefault("headers", [])
if not isinstance(raw, list):
raw = list(raw)
message["headers"] = raw
headers = MutableHeaders(raw = raw)
# Strip the internal nonce hand-off header so it never reaches the client
nonce = headers.get(_CSP_SCRIPT_NONCE_HEADER)
if nonce is not None:
del headers[_CSP_SCRIPT_NONCE_HEADER]
headers.setdefault(
"Content-Security-Policy",
_build_csp(nonce, docs = path in _DOCS_PATHS),
)
# Omit X-Frame-Options in Colab: DENY would block serve_kernel_port_as_iframe regardless of CSP.
if not _IS_COLAB and path not in _FRAME_SHELL_PATHS:
headers.setdefault("X-Frame-Options", "DENY")
headers.setdefault("X-Content-Type-Options", "nosniff")
headers.setdefault("Referrer-Policy", "no-referrer")
headers.setdefault(
"Permissions-Policy",
"camera=(), microphone=(self), geolocation=()",
)
# An API read with no cache policy and no validators can never be reused, yet Chromium and
# WebView2 still write each one to the disk cache. The UI polls several for as long as it is
# open, and the API monitor returns its whole history each time: ~200 KB of disk writes every
# 2 s from an idle desktop app. Routes with their own policy or validators keep them.
if path.startswith("/api/") and not any(
name in headers for name in _CACHE_POLICY_HEADERS
):
headers["Cache-Control"] = "no-store"
headers["server"] = "unsloth-studio"
await send(message)
await self.app(scope, receive, send_wrapper)
app.add_middleware(SecurityHeadersMiddleware)
# Swagger UI and ReDoc, on FastAPI's own paths but served entirely from this origin. FastAPI's built-in pages
# load ~2.3 MB of JavaScript from cdn.jsdelivr.net and start it with an inline script, and localStorage is
# origin-scoped, not path-scoped, so anything running on /docs can read the Unsloth tokens session.ts keeps
# there and call the API as that user. The bundles are vendored under assets/docs_ui (pinned + digest-checked by
# tests/test_docs_ui_assets.py) and the inline init runs off the same per-response nonce as the bootstrap script.
import secrets as _secrets_for_docs # noqa: E402
from fastapi.openapi.docs import ( # noqa: E402
get_redoc_html,
get_swagger_ui_html,
get_swagger_ui_oauth2_redirect_html,
)
# fastapi is unpinned, so match the opening tag by what follows it rather than by the
# surrounding whitespace and comment: a reflowed template must not 500 the page.
_SWAGGER_INIT_TAG = _re.compile(r"<script>(?=\s*const ui = SwaggerUIBundle)")
_OAUTH2_REDIRECT_TAG = _re.compile(r"<script>")
def _nonced_docs_response(html: str, *, tag: "_re.Pattern[str]") -> HTMLResponse:
"""Hand the page's own inline script a nonce; injected script never gets one."""
nonce = _secrets_for_docs.token_urlsafe(16)
nonced, replaced = tag.subn(f'<script nonce="{nonce}">', html, count = 1)
if not replaced:
# Upstream retemplated the page: fail loudly rather than serve a blank one.
raise RuntimeError(f"docs template changed, inline script tag not found: {tag.pattern!r}")
return HTMLResponse(nonced, headers = {_CSP_SCRIPT_NONCE_HEADER: nonce})
if _DOCS_ASSETS_DIR.is_dir():
app.mount(
_DOCS_ASSETS_URL,
StaticFiles(directory = _DOCS_ASSETS_DIR),
name = "docs-assets",
)
def _docs_url(request: Request, path: str) -> str:
"""Prefix with the mount point, as FastAPI's own docs routes do. Behind a path-stripping proxy (or
`uvicorn --root-path`) the browser sees the prefix the server never does, so an unprefixed URL
escapes the mapping and 404s."""
return f"{request.scope.get('root_path', '').rstrip('/')}{path}"
@app.get("/docs", include_in_schema = False)
async def swagger_ui_html(request: Request):
assets = _docs_url(request, _DOCS_ASSETS_URL)
html = get_swagger_ui_html(
openapi_url = _docs_url(request, app.openapi_url),
title = f"{app.title} - Swagger UI",
oauth2_redirect_url = _docs_url(request, "/docs/oauth2-redirect"),
swagger_js_url = f"{assets}/swagger-ui-bundle.js",
swagger_css_url = f"{assets}/swagger-ui.css",
swagger_favicon_url = f"{assets}/favicon-32x32.png",
).body.decode()
return _nonced_docs_response(html, tag = _SWAGGER_INIT_TAG)
@app.get("/docs/oauth2-redirect", include_in_schema = False)
async def swagger_ui_redirect():
# This page is nothing but an inline script, so it needs the nonce too.
html = get_swagger_ui_oauth2_redirect_html().body.decode()
return _nonced_docs_response(html, tag = _OAUTH2_REDIRECT_TAG)
@app.get("/redoc", include_in_schema = False)
async def redoc_html(request: Request):
assets = _docs_url(request, _DOCS_ASSETS_URL)
# ReDoc's bundle carries no inline init, so this one needs no nonce.
return HTMLResponse(
get_redoc_html(
openapi_url = _docs_url(request, app.openapi_url),
title = f"{app.title} - ReDoc",
redoc_js_url = f"{assets}/redoc.standalone.js",
redoc_favicon_url = f"{assets}/favicon-32x32.png",
).body.decode()
)
# Cap request bodies on protected POSTs; upload routes get explicit multipart headroom.
import json as _json_for_413 # noqa: E402
from utils.upload_limits import ( # noqa: E402
AUDIO_INPUT_MAX_BYTES,
STT_AUDIO_JSON_MAX_BYTES,
STT_AUDIO_RAW_MAX_BYTES,
LIBRARY_UPLOAD_MAX_BYTES,
SANDBOX_UPLOAD_MAX_BYTES,
UNSTRUCTURED_RECIPE_UPLOAD_MAX_BYTES,
VIDEO_INPUT_REFERENCE_JSON_MAX_BYTES,
VIDEO_INPUT_REFERENCE_MAX_BYTES,
default_request_body_limit_bytes,
upload_request_limit_bytes,
)
_BODY_PROTECTED_PREFIXES = (
# Blanket-protect the whole /v1 surface, like /api/inference: every /v1 POST buffers a JSON
# body (the multipart routes are listed as exact passthroughs below), so one prefix caps them all.
"/v1",
"/p/",
"/api/inference",
"/api/picker",
"/api/data-recipe",
"/api/datasets",
"/api/hub",
"/api/chat",
"/api/settings",
"/api/train",
"/api/export",
"/api/library",
"/api/browser",
# Unauthenticated (login, refresh): every route takes a few hundred bytes of JSON.
"/api/auth",
"/mcp",
# llama-server passthrough POSTs; keyless inference scope admits them.
"/tokenize",
"/detokenize",
# Everything else under /api. FastAPI reads a body before the route's auth dependency runs, so an unlisted
# prefix let an unauthenticated client stream an unbounded body into memory.
"/api/",
)
_DATASET_UPLOAD_PASSTHROUGH_PREFIXES = (
"/api/datasets/upload",
"/api/hub/datasets/upload",
)
_DATA_RECIPE_UNSTRUCTURED_UPLOAD_PASSTHROUGH_PREFIX = (
"/api/data-recipe/seed/upload-unstructured-file"
)
# The diffusion dataset upload (POST /api/train/diffusion/dataset) is a multipart upload
# under /api/train; like /api/datasets/upload it enforces its own cap. EXACT path.
_DIFFUSION_DATASET_UPLOAD_PATH = "/api/train/diffusion/dataset"
_STT_MULTIPART_UPLOAD_PATHS = (
"/v1/audio/transcriptions",
"/api/inference/audio/transcriptions",
"/v1/audio/translations",
"/api/inference/audio/translations",
)
_VIDEO_MULTIPART_UPLOAD_PATHS = (
"/v1/videos",
"/api/inference/videos",
)
_LIBRARY_UPLOAD_PATH = "/api/library/uploads"
# RAG document uploads (knowledge base, thread, project, extract-text): multipart, capped by RAG_MAX_UPLOAD_BYTES in the route and
# spooled by FastAPI, so they pass through on Content-Length instead of being held in memory here.
_RAG_DOCUMENT_UPLOAD_RE = _re.compile(
r"^/api/rag/(?:(?:knowledge-bases|threads|projects)/[^/]+/documents|extract-text)/?$"
)
# One file into a chat's sandbox folder (multipart, capped by the route), on both mounts of the router.
_SANDBOX_UPLOAD_RE = _re.compile(r"^/(?:api/inference|v1)/sandbox/[^/]+/upload/?$")
_UPLOAD_PASSTHROUGH_RE = _re.compile(
f"(?:{_RAG_DOCUMENT_UPLOAD_RE.pattern})|(?:{_SANDBOX_UPLOAD_RE.pattern})"
)
# Streamed to disk and capped by the route itself; buffering here would hold 200 MiB in memory.
_AUDIO_INPUT_UPLOAD_PATHS = ("/api/inference/audio/inputs", "/v1/audio/inputs")
_BODY_UPLOAD_PASSTHROUGH_PREFIXES = (
*_DATASET_UPLOAD_PASSTHROUGH_PREFIXES,
_DATA_RECIPE_UNSTRUCTURED_UPLOAD_PASSTHROUGH_PREFIX,
)
# Matched by EXACT path (multipart uploads only), so sibling JSON sub-routes keep the normal cap.
_BODY_UPLOAD_PASSTHROUGH_EXACT_PATHS = (
_DIFFUSION_DATASET_UPLOAD_PATH,
*_AUDIO_INPUT_UPLOAD_PATHS,
*_STT_MULTIPART_UPLOAD_PATHS,
*_VIDEO_MULTIPART_UPLOAD_PATHS,
_LIBRARY_UPLOAD_PATH,
)
# Which of those may arrive with no Content-Length and be counted instead of refused. Deliberately NOT the
# whole set above: this middleware runs before authentication, and a counted body is a held body, so the dataset
# path (whose cap is the configurable upload limit, up to 8 GB) and the 25 MB stt paths keep their 411. The
# videos reference image is bounded at 32 MB.
_CHUNKED_UPLOAD_EXACT_PATHS = _VIDEO_MULTIPART_UPLOAD_PATHS
def _get_upload_passthrough_request_max_bytes(path: str) -> int:
if path.startswith(_DATA_RECIPE_UNSTRUCTURED_UPLOAD_PASSTHROUGH_PREFIX):
return upload_request_limit_bytes(UNSTRUCTURED_RECIPE_UPLOAD_MAX_BYTES)
if path.rstrip("/") in _STT_MULTIPART_UPLOAD_PATHS:
return upload_request_limit_bytes(STT_AUDIO_RAW_MAX_BYTES)
if path.rstrip("/") in _VIDEO_MULTIPART_UPLOAD_PATHS:
return max(
upload_request_limit_bytes(VIDEO_INPUT_REFERENCE_MAX_BYTES),
VIDEO_INPUT_REFERENCE_JSON_MAX_BYTES,
)
if path.rstrip("/") == _LIBRARY_UPLOAD_PATH:
return upload_request_limit_bytes(LIBRARY_UPLOAD_MAX_BYTES)
if path.rstrip("/") in _AUDIO_INPUT_UPLOAD_PATHS:
return AUDIO_INPUT_MAX_BYTES
if _SANDBOX_UPLOAD_RE.match(path):
return upload_request_limit_bytes(SANDBOX_UPLOAD_MAX_BYTES)
if _RAG_DOCUMENT_UPLOAD_RE.match(path):
from core.rag import config as _rag_config
# RAG_MAX_UPLOAD_BYTES=0 means no cap, as the route treats it.
if _rag_config.MAX_UPLOAD_BYTES <= 0:
return sys.maxsize
return upload_request_limit_bytes(_rag_config.MAX_UPLOAD_BYTES)
# The trailing-slash variant reaches this middleware BEFORE the router's redirect_slashes
# 307, so it must resolve to the same cap. JSON sub-routes keep extra path components.
if (
path.startswith(_DATASET_UPLOAD_PASSTHROUGH_PREFIXES)
or path.rstrip("/") == _DIFFUSION_DATASET_UPLOAD_PATH
):
return upload_request_limit_bytes()
return default_request_body_limit_bytes()
def _get_request_body_max_bytes(path: str) -> int:
if path.startswith("/api/inference/audio/transcribe/raw"):
return STT_AUDIO_RAW_MAX_BYTES
if path.startswith("/api/inference/audio/transcribe"):
return STT_AUDIO_JSON_MAX_BYTES
# multipart headroom over the raw stt cap for the openai transcription/translation routes
if path.rstrip("/") in _STT_MULTIPART_UPLOAD_PATHS:
return upload_request_limit_bytes(STT_AUDIO_RAW_MAX_BYTES)
if path.rstrip("/") in _VIDEO_MULTIPART_UPLOAD_PATHS:
return max(
upload_request_limit_bytes(VIDEO_INPUT_REFERENCE_MAX_BYTES),
VIDEO_INPUT_REFERENCE_JSON_MAX_BYTES,
)
return default_request_body_limit_bytes()
async def _send_411(send) -> None:
payload = _json_for_413.dumps(
{"detail": "Content-Length required for upload requests."},
).encode("utf-8")
await send(
{
"type": "http.response.start",
"status": 411,
"headers": [
(b"content-type", b"application/json"),
(b"content-length", str(len(payload)).encode("ascii")),
],
}
)
await send({"type": "http.response.body", "body": payload, "more_body": False})
async def _send_413(send, total_bytes: int, max_bytes: int) -> None:
payload = _json_for_413.dumps(
{"detail": (f"Request body too large ({total_bytes:,} bytes; max {max_bytes:,}).")},
).encode("utf-8")
await send(
{
"type": "http.response.start",
"status": 413,
"headers": [
(b"content-type", b"application/json"),
(b"content-length", str(len(payload)).encode("ascii")),
],
}
)
await send({"type": "http.response.body", "body": payload, "more_body": False})
class MaxBodyMiddleware:
"""Reject oversized bodies on protected POST/PUT/PATCH; raw ASGI so chunked uploads cannot bypass the cap."""
def __init__(
self,
app,
max_bytes_getter,
protected_prefixes: tuple,
request_max_bytes_getter = None,
upload_passthrough_prefixes: tuple = (),
upload_passthrough_max_bytes_getter = None,
upload_passthrough_exact_paths: tuple = (),
chunked_upload_exact_paths: tuple = (),
upload_passthrough_pattern = None,
):
self.app = app
self.max_bytes_getter = max_bytes_getter
self.protected_prefixes = protected_prefixes
self.request_max_bytes_getter = request_max_bytes_getter
self.upload_passthrough_prefixes = upload_passthrough_prefixes
self.upload_passthrough_max_bytes_getter = upload_passthrough_max_bytes_getter
# Exact path, not prefix: sibling JSON sub-routes must keep the normal (small) body cap.
self.upload_passthrough_exact_paths = upload_passthrough_exact_paths
# The subset of those allowed to omit Content-Length; the rest still get a 411.
self.chunked_upload_exact_paths = chunked_upload_exact_paths
# Uploads whose path carries an id (RAG documents), matched by a compiled pattern.
self.upload_passthrough_pattern = upload_passthrough_pattern
def _is_upload_passthrough(self, path: str) -> bool:
# Exact paths also match their trailing-slash variant (this runs before redirect_slashes).
return (
path.rstrip("/") in self.upload_passthrough_exact_paths
or any(path.startswith(p) for p in self.upload_passthrough_prefixes)
or (
self.upload_passthrough_pattern is not None
and self.upload_passthrough_pattern.match(path) is not None
)
)
def _upload_passthrough_max_bytes(self, path: str) -> int:
if self.upload_passthrough_max_bytes_getter is None:
return int(self.max_bytes_getter())
try:
return int(self.upload_passthrough_max_bytes_getter(path))
except TypeError:
try:
return int(self.upload_passthrough_max_bytes_getter())
except Exception:
return int(self.max_bytes_getter())
except Exception:
return int(self.max_bytes_getter())
def _request_max_bytes(self, path: str) -> int:
if self.request_max_bytes_getter is None:
return int(self.max_bytes_getter())
try:
return int(self.request_max_bytes_getter(path))
except Exception:
return int(self.max_bytes_getter())
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
method = scope.get("method", "").upper()
path = scope.get("path", "")
# Under `--root-path /x`, uvicorn keeps the prefix in `path`; match on the route path the router sees.
root_path = scope.get("root_path") or ""
if root_path and path.startswith(root_path):
path = path[len(root_path) :] or "/"
# DELETE too: several routes take a JSON body on DELETE (delete-cached, bulk thread delete).
if method not in ("POST", "PUT", "PATCH", "DELETE") or not any(
path.startswith(p) for p in self.protected_prefixes
):
await self.app(scope, receive, send)
return
max_bytes = self._request_max_bytes(path)
declared = None
for name, value in scope.get("headers", []):
if name == b"content-length":
try:
declared = int(value.decode("latin-1"))
except (ValueError, UnicodeDecodeError):
declared = None
break
if method != "DELETE" and self._is_upload_passthrough(path):
upload_max_bytes = self._upload_passthrough_max_bytes(path)
if declared is not None:
if declared > upload_max_bytes:
await _send_413(send, declared, upload_max_bytes)
return
await self.app(scope, receive, send)
return
if path.rstrip("/") not in self.chunked_upload_exact_paths:
await _send_411(send)
return
max_bytes = upload_max_bytes
if declared is not None and declared > max_bytes:
await _send_413(send, declared, max_bytes)
return
chunks: list = []
total = 0
while True:
msg = await receive()
mtype = msg.get("type")
if mtype == "http.disconnect":
return
if mtype != "http.request":
# Mid-stream unexpected frame: forwarding would corrupt downstream
return
body = msg.get("body", b"") or b""
if body:
total += len(body)
if total > max_bytes:
await _send_413(send, total, max_bytes)
return
chunks.append(body)
if not msg.get("more_body", False):
break
replayed = {"sent": False}
async def replay_receive():
if not replayed["sent"]:
replayed["sent"] = True
return {
"type": "http.request",
"body": b"".join(chunks),
"more_body": False,
}
# After replay, fall through so http.disconnect still propagates.
return await receive()
await self.app(scope, replay_receive, send)
# Added before MaxBodyMiddleware so it runs inside it: the body it reads is already capped.
from utils.model_aliases import ModelAliasMiddleware # noqa: E402
app.add_middleware(ModelAliasMiddleware)
app.add_middleware(
MaxBodyMiddleware,
max_bytes_getter = default_request_body_limit_bytes,
protected_prefixes = _BODY_PROTECTED_PREFIXES,
request_max_bytes_getter = _get_request_body_max_bytes,
upload_passthrough_prefixes = _BODY_UPLOAD_PASSTHROUGH_PREFIXES,
upload_passthrough_max_bytes_getter = _get_upload_passthrough_request_max_bytes,
upload_passthrough_exact_paths = _BODY_UPLOAD_PASSTHROUGH_EXACT_PATHS,
chunked_upload_exact_paths = _CHUNKED_UPLOAD_EXACT_PATHS,
upload_passthrough_pattern = _UPLOAD_PASSTHROUGH_RE,
)
# Tracks in-flight inference requests for idle auto-unload; off -> passthrough.
from core.inference.llama_keepwarm import LlamaKeepWarmMiddleware # noqa: E402
app.add_middleware(LlamaKeepWarmMiddleware)
from starlette.responses import RedirectResponse as _RedirectResponse # noqa: E402
@app.get("/recipes", include_in_schema = False)
@app.get("/recipes/{rest:path}", include_in_schema = False)
async def _recipes_redirect(rest: str = ""):
target = "/data-recipes" + (("/" + rest) if rest else "")
return _RedirectResponse(url = target, status_code = 308)
from utils.host_policy import (
cors_origin_regex_for_mode,
cors_origins_for_mode,
) # noqa: E402
class RemoteAccessCORSMiddleware(CORSMiddleware):
"""Admit the published Cloudflare origin, on top of the startup allowlist."""
def __init__(self, cors_app, *, remote_access_state, **kwargs):
self.remote_access_state = remote_access_state
super().__init__(cors_app, **kwargs)
def is_allowed_origin(self, origin: str) -> bool:
# The tunnel names ONE origin to admit, it is not a switch admitting every origin: api-only is
# locked to the Tauri app (run.py) and Settings > Remote access must not hand that lock to any
# open page. The tunnel-served UI calls relative URLs and is already same-origin; this is for a
# browser that does reach the API cross-origin from the tunnel's own document.
published = getattr(self.remote_access_state, "cloudflare_url", None)
if published:
tunnel_origin = _origin_of(published)
if tunnel_origin is not None and tunnel_origin == _origin_of(origin):
return True
return super().is_allowed_origin(origin)
_cors_origins = cors_origins_for_mode(
api_only = os.environ.get("UNSLOTH_API_ONLY") == "1",
secure = os.environ.get("UNSLOTH_SECURE") == "1",
)
_cors_origin_regex = cors_origin_regex_for_mode(
api_only = os.environ.get("UNSLOTH_API_ONLY") == "1",
secure = os.environ.get("UNSLOTH_SECURE") == "1",
)
app.add_middleware(
RemoteAccessCORSMiddleware,
remote_access_state = app.state,
allow_origins = _cors_origins,
allow_origin_regex = _cors_origin_regex,
allow_credentials = True,
allow_methods = ["*"],
allow_headers = ["*"],
# allow_headers is the REQUEST side; a response header is unreadable to JS unless
# exposed, and Studio is cross-origin from tauri://localhost and tunnels.
expose_headers = [
"X-Unsloth-Conflict-Kind",
"X-Unsloth-Refusal",
"x-typesafe-request-id",
"X-Unsloth-Monitor-ID",
*_hub_endpoint_proxy.EXPOSED_HEADERS,
*_browser_routes.EXPOSED_HEADERS,
],
# is_allowed_origin closes the moment the tunnel URL clears, but a preflight already cached by the browser
# does not. Measured in WebKit: with Starlette's 600s default, a state-changing request still REACHED the
# server after remote access was stopped. Keep the stale window short.
max_age = 60,
)
from utils.keyless_api_access import KeylessToolPolicyMiddleware # noqa: E402
app.add_middleware(KeylessToolPolicyMiddleware)
from utils.remote_access_settings import RemoteAccessStopResponseMiddleware # noqa: E402
app.add_middleware(RemoteAccessStopResponseMiddleware)
from auth.x_api_key import XApiKeyMiddleware # noqa: E402
# Added last so it is outermost: every Authorization reader below sees the rewritten header.
app.add_middleware(XApiKeyMiddleware)
app.include_router(auth_router, prefix = "/api/auth", tags = ["auth"])
app.include_router(
__import__("routes.accounts", fromlist = ["router"]).router,
prefix = "/api/accounts",
tags = ["accounts"],
)
app.include_router(training_router, prefix = "/api/train", tags = ["training"])
app.include_router(models_router, prefix = "/api/models", tags = ["models"])
app.include_router(chat_history_router, prefix = "/api/chat", tags = ["chat"])
app.include_router(research_runs_router, prefix = "/api/chat/research-runs", tags = ["research-runs"])
app.include_router(
chat_generation_runs_router,
prefix = "/api/inference/chat-runs",
tags = ["inference"],
)
app.include_router(inference_router, prefix = "/api/inference", tags = ["inference"])
# Unsloth-only inference endpoints (cancel, etc.) are not on the /v1 OpenAI-compat prefix.
app.include_router(inference_studio_router, prefix = "/api/inference", tags = ["inference"])
# Unsloth-only text-to-video endpoints; not exposed on the /v1 OpenAI-compat prefix.
app.include_router(video_router, prefix = "/api/inference", tags = ["inference"])
app.include_router(video_openai_router, prefix = "/api/inference", tags = ["inference"])
app.include_router(video_openai_router, prefix = "/v1", tags = ["openai-compat"])
app.include_router(inference_router, prefix = "/v1", tags = ["openai-compat"])
app.include_router(systemone_router, prefix = "/v1", tags = ["systemone"])
# llama-server / Ollama discovery probes. Declares its own full paths (/props, /version, /api/tags, ...) so it
# needs no prefix, and must be registered ahead of the SPA catch-all in serve_frontend() or /props and /version
# go on resolving to index.html with a 200.
app.include_router(llama_compat_router, tags = ["openai-compat"])
app.include_router(preview_router, prefix = "/p", tags = ["preview"])
app.include_router(providers_router, prefix = "/api/providers", tags = ["providers"])
app.include_router(openai_codex_auth_router, prefix = "/api/providers", tags = ["providers"])
app.include_router(settings_router, prefix = "/api/settings", tags = ["settings"])
app.include_router(sandbox_capability_router, prefix = "/api/sandbox", tags = ["sandbox"])
app.include_router(mcp_servers_router, prefix = "/api/mcp/servers", tags = ["mcp"])
app.include_router(skills_router, prefix = "/api/skills", tags = ["skills"])
app.include_router(prompts_router, prefix = "/api/prompts", tags = ["prompts"])
app.include_router(library_router, prefix = "/api/library", tags = ["library"])
app.include_router(profile_stats_router, prefix = "/api/profile", tags = ["profile"])
app.include_router(datasets_router, prefix = "/api/datasets", tags = ["datasets"])
app.include_router(data_recipe_router, prefix = "/api/data-recipe", tags = ["data-recipe"])
app.include_router(llama_router, prefix = "/api/llama", tags = ["llama"])
app.include_router(engines_router, prefix = "/api/engines", tags = ["engines"])
app.include_router(whisper_router, prefix = "/api/whisper", tags = ["whisper"])
app.include_router(npu_router, prefix = "/api/npu", tags = ["npu"])
app.include_router(export_router, prefix = "/api/export", tags = ["export"])
app.include_router(external_import_router, prefix = "/api/import", tags = ["import"])
app.include_router(rag_router, prefix = "/api/rag", tags = ["rag"])
app.include_router(training_history_router, prefix = "/api/train", tags = ["training-history"])
app.include_router(hub_inventory_router, prefix = "/api/hub", tags = ["hub"])
app.include_router(hub_datasets_router, prefix = "/api/hub/datasets", tags = ["hub"])
app.include_router(picker_templates_router, prefix = "/api/picker", tags = ["picker"])
app.include_router(hub_token_router, prefix = "/api/hub", tags = ["hub"])
app.include_router(
_build_modelscope_router(browser = True),
prefix = _MODELSCOPE_BROWSER_PREFIX,
include_in_schema = False,
)
for _prefix, _upstream, _pages in (
(_hub_endpoint_proxy.HUB_PREFIX, browser_hf_endpoint, True),
(_hub_endpoint_proxy.DATASETS_SERVER_PREFIX, get_hf_datasets_server, False),
):
app.include_router(
_hub_endpoint_proxy.build_router(_prefix, _upstream, anonymous_pages = _pages),
prefix = _prefix,
tags = ["hub"],
)
app.include_router(youtube_router, prefix = "/api/youtube", tags = ["youtube"])
app.include_router(_browser_routes.router, prefix = "/api/browser", tags = ["browser"])
# Re-wrap /v1/* client errors into OpenAI/Anthropic envelopes; non-/v1 keeps {"detail": ...}.
install_api_error_handlers(app)
# /api/health has a hard deadline: preflight/backend.rs probes it with a 2s timeout right after TAURI_PORT is
# emitted, and a timeout is not retried, falling through to "desktop_owned_backend_starting", a dead end the
# user must clear by hand. A target, not a guarantee: the wait polls on the event loop and a C-extension import
# can hold the GIL past it. 1.5s measured a 1.742s worst case; 1.0s buys one extra provisional reply.
_HEALTH_DETECT_BUDGET_S = 1.0
async def _await_hardware_detection(budget: float) -> bool:
"""Wait up to ``budget`` seconds for DEVICE to be set. True iff it is. Polls on the event loop
instead of awaiting ensure_hardware_detected() in a thread: asyncio.wait_for cannot cancel a
to_thread, so a timed-out call holds the executor slot for the rest of the import and a polled
endpoint would drain the pool. Returns False without kicking anything when the warm is switched
off. Health is probed automatically, so kicking detection here would import torch on every such
host and the switch would buy nothing."""
if os.environ.get(DISABLE_ENV_VAR) == "1":
return _hw_module.DETECTION_COMPLETE.is_set() and _hw_module.DEVICE is not None
# The event AND DEVICE: branches assign DEVICE and keep probing, and shutdown clears DEVICE then the
# event, so event-set-with-DEVICE-None would serve a torn-down verdict.
if _hw_module.DETECTION_COMPLETE.is_set() and _hw_module.DEVICE is not None:
return True
start_background_detection()
loop = asyncio.get_running_loop()
deadline = loop.time() + budget
while not (_hw_module.DETECTION_COMPLETE.is_set() and _hw_module.DEVICE is not None):
if loop.time() >= deadline:
return False
await asyncio.sleep(0.02)
return True
def _hardware_snapshot() -> Optional[tuple[bool, Optional[str], Optional[str]]]:
"""``(chat_only, chat_only_reason, chat_only_detail)`` if detection is settled, else ``None``. A
seqlock read rather than ``_DETECT_LOCK``: that lock would park the endpoint for the whole torch
import. A forced re-detect clears the event on the way in and bumps the generation before
setting it again, so a read bracketed by both lands wholly before or after one pass, never
mid-pass where CHAT_ONLY is back to True and the reason to None. That middle must not be
published: config/env.ts caches the first reply carrying `device_type` as authoritative, and the
sidebar's recovery poll runs only while it reads `chat_only_reason == "mlx_unavailable"`."""
for _ in range(3):
if not _hw_module.DETECTION_COMPLETE.is_set():
return None
generation = _hw_module.DETECTION_GENERATION
device = _hw_module.DEVICE
chat_only = bool(_hw_module.CHAT_ONLY)
# Refreshed, not the frozen global: the three inventory-sensitive verdicts can change after startup (an eGPU
# attached, a driver that finished restarting). Reason and detail come back together, or a forced re-detect
# starting in between would pair this reply's reason with a detail from another pass.
try:
reason, detail = _hw_module.current_chat_only_verdict()
except Exception:
reason = getattr(_hw_module, "CHAT_ONLY_REASON", None)
detail = getattr(_hw_module, "CHAT_ONLY_DETAIL", None)
if (
device is not None
and _hw_module.DETECTION_COMPLETE.is_set()
and _hw_module.DETECTION_GENERATION == generation
):
return chat_only, reason, detail
return None
# How long a self-heal that has not started yet may keep holding a verdict back once the warm that schedules
# it is over. start_mlx_autorepair_if_needed() runs immediately after join_background_warm(), so the handoff is
# the gap this covers; the warm itself is covered by _torch_warm_in_progress().
_MLX_PRESTART_GRACE_AFTER_WARM_S = 30.0
# Absolute backstop, measured from the first hold. _torch_warm_in_progress() goes false when the warm thread
# dies for any reason, but a warm parked forever inside an import never does, and "the scheduler is still
# coming" would then be a permanent answer. Well above the warm's own worst case.
_MLX_PRESTART_CEILING_S = 900.0
_MLX_PRESTART_LOCK = threading.Lock()
# (detection generation, first hold, first tick the warm was seen STOPPED, None while it runs). Keyed by
# generation because detection is not once-per-process: a re-detect that republishes mlx_unavailable is a new
# verdict and gets its own window. The third field is when the warm was first seen stopped, not when it was last
# seen running, because the final stages are C-extension imports that hold the GIL for seconds at a time, so
# measuring the grace from the last observed poll would start it in the past and expire it before the handoff.
_mlx_prestart_hold: Optional[tuple[int, float, Optional[float]]] = None
# Indirected so tests can drive the windows without sleeping through them.
_mlx_prestart_clock = time.monotonic
def _mlx_prestart_hold_ok(generation: int) -> bool:
"""True while a self-heal that has not started yet may still hold a verdict back."""
global _mlx_prestart_hold
now = _mlx_prestart_clock()
warming = _torch_warm_in_progress()
with _MLX_PRESTART_LOCK:
held = _mlx_prestart_hold
if held is None or held[0] != generation:
_mlx_prestart_hold = (generation, now, None if warming else now)
return True
_, first, stopped_seen = held
if now - first >= _MLX_PRESTART_CEILING_S:
return False
if warming:
# Still (or again) running, so the handoff has not happened yet and any earlier
# stopped reading was a lull, not the end.
_mlx_prestart_hold = (generation, first, None)
return True
if stopped_seen is None:
# First time this pass has seen it stopped: the grace starts here, whenever the
# warm actually ended, so a gap in polling cannot spend it before it opens.
stopped_seen = now
_mlx_prestart_hold = (generation, first, stopped_seen)
return now - stopped_seen < _MLX_PRESTART_GRACE_AFTER_WARM_S
def _superseded_by_mlx_repair(snapshot: Optional[tuple[bool, Optional[str]]]) -> bool:
"""True when the MLX self-heal is about to replace this settled verdict. Scoped to /api/health
rather than folded into ``_hardware_snapshot()``: the launcher's watchdog reads /api/liveness
and holds its startup grace open while hardware_detecting is set, so a 15-minute reinstall must
not stretch that grace. Bounded, never open-ended. A live worker holds the verdict for as long
as its install takes, capped by mlx_repair._WORKER_BUDGET_S. A repair that has not started yet
is only a promise, and this is where that promise expires: the hold lasts while the warm does
and a short handoff beyond it, under an absolute ceiling for the warm that never ends."""
if snapshot is None:
return False
if not _hw_module.verdict_pending_mlx_repair(snapshot[0], snapshot[1]):
return False
try:
from utils.mlx_repair import mlx_repair_started
# Read after the predicate, so a repair that claims the latch between the two calls
# resolves the safe way: still held, and now on the worker rather than on a clock.
if mlx_repair_started():
return True
except Exception as exc:
logger.debug("MLX repair start check failed, holding on the pre-start window: %s", exc)
return _mlx_prestart_hold_ok(_hw_module.DETECTION_GENERATION)
def _torch_warm_in_progress() -> bool:
"""True while the coordinated warm thread is still working through its stages. A separate field
from ``hardware_detecting`` on purpose. Hardware detection is only ``_STAGES[0]``;
inference_backend, transformers and datasets run after it, and those C-extension imports can
hold the GIL for seconds at a time, so a launcher ending its startup grace on
``hardware_detecting`` alone ends it with the expensive half of the warm still ahead. But that
marker also means "this hardware verdict is provisional, re-read it", and
config/hardware-verdict.ts keeps the UI provisional and polling while it is set, so keeping it
lit through datasets would hide Train for the whole warm. Two meanings, two fields. False
whenever no warm thread is running, which keeps the deferred case working: with
UNSLOTH_STUDIO_DISABLE_TORCH_WARM=1 the warm never starts, and one retired mid-stage by a
shutdown never finishes, so deriving this from "not finished" would report warming forever."""
status = warm_status()
return bool(status["started"] and not status["finished"] and status["alive"])
# Modules that expose generation_in_flight(). The three engines answer for the render itself;
# routes.inference answers for the image-persist tail, which outlives the engine's own marker.
_MEDIA_BACKEND_MODULES = (
"core.inference.video",
"core.inference.diffusion",
"core.inference.sd_cpp_backend",
"core.inference.mlx_diffusion_backend",
"routes.inference",
)
def _media_generation_active() -> bool:
"""Check imported media backends without importing, constructing, or locking them."""
for module_name in _MEDIA_BACKEND_MODULES:
module = sys.modules.get(module_name)
if module is None:
continue
try:
if module.generation_in_flight():
return True
except Exception:
continue
return False
def _inference_active() -> bool:
"""True while at least one generation is in flight, published so the desktop health watchdog can tell a
backend that is busy serving from one that has died: a saturated host can stall the event loop past a
probe budget, and killing there ends a response the user is still waiting on. Failures report "not
busy"."""
try:
from state import active_generations
if active_generations.count() > 0:
return True
except Exception:
pass
return _media_generation_active()
@app.get("/api/liveness")
async def liveness_check():
"""Cheap process liveness for desktop port validation."""
alive = {
"status": "alive",
"service": "Unsloth UI Backend",
"desktop_protocol_version": 1,
# Lockstep with DESKTOP_MANAGEABILITY_VERSION in
# studio/src-tauri/src/preflight/version.rs and `desktop-capabilities`.
"desktop_manageability_version": 2,
"supports_desktop_auth": True,
"supports_desktop_backend_ownership": True,
"studio_root_id": _studio_root_id(),
**({"desktop_owner": owner} if (owner := _desktop_owner()) else {}),
}
# Same unsettled markers /api/health publishes, and for the desktop health watchdog they are the point of the
# route: it probes liveness every 15s and holds its startup grace open until a reply says the warm-up is
# over, because the warm thread's `import torch` holds the GIL. The watchdog reads torch_warm_in_progress for
# that, not hardware_detecting: later transformers and datasets stages can also hold the GIL after detection
# settles. Both are non-blocking reads of module-level state.
if _torch_warm_in_progress():
alive["torch_warm_in_progress"] = True
# Startup is not the only window where a healthy backend can miss probes: an oversubscribed host generating
# on every slot stalls this loop the same way. The watchdog widens its failure budget on this marker rather
# than ending a stream that is still producing tokens.
if _inference_active():
alive["inference_active"] = True
if _hardware_snapshot() is None:
alive["hardware_detecting"] = True
if os.environ.get(DISABLE_ENV_VAR) == "1":
# Nothing is detecting while the warm is switched off, so the verdict will not
# settle on its own. Say so, or the watchdog holds its grace open for nothing.
alive["hardware_detection_deferred"] = True
return alive
async def _desktop_shell_subject(request: Request) -> Optional[str]:
"""Owner subject for a request carrying the desktop secret, None without one: on a multi-account install desktop-login mints no session, so the shell proves ownership with the secret itself."""
secret = request.headers.get("x-desktop-secret")
if secret is None:
return None
from starlette.concurrency import run_in_threadpool
if await run_in_threadpool(storage.validate_desktop_secret, secret) is None:
raise HTTPException(status_code = 401, detail = "Desktop authentication failed")
return storage.DEFAULT_ADMIN_USERNAME
@app.get("/api/health")
async def health_check(request: Request):
"""Liveness plus launcher capability bits; host fingerprint gated on a bearer.
Unauthenticated callers get non-sensitive fields (service, studio_root_id,
chat_only, desktop_*, native_path_leases_supported) to re-adopt a sibling
backend and gate UI before a token exists. version / studio_version /
device_type require a bearer since they fingerprint the host.
"""
# Wait for detection rather than grey out Train/Export on a GPU host, but only up to a
# budget. Called for the wait, not the answer: _hardware_snapshot() below decides the reply.
await _await_hardware_detection(_HEALTH_DETECT_BUDGET_S)
# Snapshot, not a bare global read: a forced re-detect can start at any moment.
snapshot = _hardware_snapshot()
# A chat-only verdict the MLX self-heal is about to overturn is not an answer yet. Hold it back and keep
# replying provisionally, or the Mac gets Train greyed out under a tooltip the reinstall makes wrong minutes
# later. Video does not wait on this: it runs on Metal without MLX.
mlx_repairing = _superseded_by_mlx_repair(snapshot)
if mlx_repairing:
snapshot = None
base = {
"status": "healthy",
"timestamp": datetime.now().isoformat(),
"service": "Unsloth UI Backend",
# Literal True with no snapshot, not a CHAT_ONLY read: a pass in flight sets the flag False
# before a probe that can still fall back to CPU.
"chat_only": snapshot[0] if snapshot is not None else True,
"desktop_protocol_version": 1,
# Lockstep: see the note in /api/liveness above.
"desktop_manageability_version": 2,
"supports_desktop_auth": True,
"supports_desktop_backend_ownership": True,
# Opaque per-install id; launchers reject sibling Unsloth instances on the same port.
"studio_root_id": _studio_root_id(),
"native_path_leases_supported": native_path_leases_supported(),
# Unauthenticated on purpose: an endpoint URL is not a host fingerprint,
# and the frontend needs it before a token exists.
**_reportable_hf_endpoints(request),
"hub_source": _active_hub_source(),
"hub_proxy": _hub_endpoint_proxy.relay_path(
_hub_endpoint_proxy.HUB_PREFIX,
browser_hf_endpoint(),
_HF_ENDPOINT_DEFAULTS["hf_endpoint"],
),
"datasets_server_proxy": _hub_endpoint_proxy.relay_path(
_hub_endpoint_proxy.DATASETS_SERVER_PREFIX,
get_hf_datasets_server(),
_HF_ENDPOINT_DEFAULTS["hf_datasets_server"],
),
**({"desktop_owner": owner} if (owner := _desktop_owner()) else {}),
}
# Lockstep with /api/liveness: the launcher falls back to this route on a backend too old
# to have liveness, so the warm marker has to reach it by the same path.
if _torch_warm_in_progress():
base["torch_warm_in_progress"] = True
# Lockstep with /api/liveness for the same reason: the fallback route has to carry the
# busy marker too, or an older backend loses the widened budget.
if _inference_active():
base["inference_active"] = True
if snapshot is None:
# chat_only above is the pre-detection default, not a measurement; clients should re-read.
base["hardware_detecting"] = True
# Not for a held-back verdict: the repair settles it on its own, and "deferred" means
# nothing ever will, which env.ts answers by storing the conservative chat_only.
if os.environ.get(DISABLE_ENV_VAR) == "1" and not mlx_repairing:
# Nothing is detecting until a hardware-dependent operation runs; say so instead of making clients poll.
base["hardware_detection_deferred"] = True
subject = await _desktop_shell_subject(request)
auth = request.headers.get("authorization", "")
bearer = auth.split(" ", 1)[1] if auth.lower().startswith("bearer ") else None
if subject is None:
try:
from auth.authentication import credentials_for_token
from auth.authentication import get_current_subject as _gcs
creds = await credentials_for_token(request, bearer)
if creds is None:
return base
# Must await: a bare coroutine is truthy and would skip the auth check
subject = await _gcs(creds)
except HTTPException:
return base
except Exception:
return base
if not subject:
return base
# Re-read: the bearer check awaits, so a forced re-detect can land in between.
snapshot = _hardware_snapshot()
if _superseded_by_mlx_repair(snapshot):
mlx_repairing = True
snapshot = None
platform_map = {"darwin": "mac", "win32": "windows", "linux": "linux"}
device_type = platform_map.get(sys.platform, sys.platform)
# Alongside device_type, not folded into it: "mac" is every Darwin host, and an Intel Mac with a discrete GPU
# spills to system RAM like a PC while Apple Silicon has one pool and nowhere to spill. Same gate the Metal
# context budget uses, and a pure platform check, so a health poll pays nothing.
from utils.hardware import is_apple_silicon
authed = {
**base,
"version": UNSLOTH_VERSION,
"studio_version": STUDIO_VERSION,
# API-screen fields (authed-only; they fingerprint how the host is exposed).
"cloudflare_url": getattr(request.app.state, "cloudflare_url", None),
"server_url": getattr(request.app.state, "server_url", None),
"secure": bool(getattr(request.app.state, "secure", False)),
}
if snapshot is not None:
# Why chat_only is set; fingerprints the host, so keep it authed. One snapshot for all three.
authed["chat_only"] = snapshot[0]
authed["chat_only_reason"] = snapshot[1]
# What specifically blocked that reason, when detection recorded one. Only the MLX gate does today, and only
# because it is all-or-nothing: without it the greyed-out Train row can only say "run `unsloth studio
# update`". From the snapshot, so it cannot come from a different detection pass than the reason beside it.
authed["chat_only_detail"] = snapshot[2]
# A closed AMD device node (#14067): Repair installation cannot fix group membership.
try:
authed["chat_only_hint"] = (
_hw_module.chat_only_permission_hint(snapshot[1], snapshot[2])
if snapshot[0]
else None
)
except Exception:
authed["chat_only_hint"] = None
authed["device_type"] = device_type
authed["apple_silicon"] = is_apple_silicon()
from utils.paths.file_manager import file_manager_kind
authed["file_manager"] = file_manager_kind()
# base predates the bearer await; never ship "detecting" beside a measurement.
authed.pop("hardware_detecting", None)
# Same for the deferred marker: the client reads it first and would keep the old reason.
authed.pop("hardware_detection_deferred", None)
# torch_warm_in_progress deliberately survives: it does not qualify the verdict below, and dropping it
# here would hand the watchdog the same too-early "startup is over" this field exists to replace.
else:
# A re-detect started during the bearer await and base carries no chat_only_reason, so a client reading this
# as measured would store reason null and stop the sidebar's recovery poll. Mark provisional and omit
# device_type: env.ts treats it as authoritative.
authed["hardware_detecting"] = True
if mlx_repairing:
# base was built before the repair was noticed, so drop a marker that now
# contradicts it: the repair will settle this verdict, deferred means nothing will.
authed.pop("hardware_detection_deferred", None)
return authed
@app.get("/api/studio/install-source")
def studio_install_source(_current_subject: str = Depends(get_current_subject)):
"""Return source-aware install metadata without remote update checks."""
return get_studio_install_source_status(UNSLOTH_VERSION)
@app.get("/api/studio/update-status")
def studio_update_status(_current_subject: str = Depends(get_current_subject)):
"""Return source-aware manual update status for browser-served Unsloth."""
return get_studio_update_status(UNSLOTH_VERSION)
@app.get("/api/studio/release-notes")
def studio_release_notes(
version: str = Query(..., max_length = 64),
refresh: bool = Query(False),
_current_subject: str = Depends(get_current_subject),
):
"""Return the newest release's notes. `version` is echoed, not looked up."""
if not is_supported_version_query(version):
raise HTTPException(status_code = 422, detail = "Invalid version.")
return get_release_notes(version, refresh = refresh)
@app.get(
"/api/studio/download-transport-capabilities",
response_model = TransportCapabilities,
)
def studio_download_transport_capabilities(
probe: bool = False, _current_subject: str = Depends(get_current_subject)
):
# Sync def, so FastAPI runs this in the threadpool and an opted-in probe cannot block the loop.
return asdict(get_download_transport_capabilities(probe = probe))
@app.post(
"/api/shutdown",
dependencies = [Depends(get_current_subject), Depends(auth_policy.require_owner)],
)
async def shutdown_server(request: Request, current_subject: str = Depends(get_current_subject)):
"""Gracefully shut down the Unsloth Studio server.
Called by the frontend quit dialog so users can stop the server from the UI
without the CLI or killing the process manually.
"""
return _schedule_shutdown(request)
@app.post("/api/desktop/shutdown")
async def desktop_shutdown_server(request: Request):
"""The desktop shell's quit path, authenticated by its secret."""
if await _desktop_shell_subject(request) is None:
raise HTTPException(status_code = 401, detail = "Desktop authentication failed")
return _schedule_shutdown(request)
_shutdown_thread_lock = threading.Lock()
def _schedule_shutdown(request: Request) -> dict:
async def _delayed_shutdown():
await asyncio.sleep(0.2) # Let the HTTP response return first
trigger = getattr(request.app.state, "trigger_shutdown", None)
if trigger is not None:
# On its own thread, like the signal path: the cleanup waits seconds on subprocesses
# (llama-server alone up to 5 s SIGTERM + 5 s SIGKILL), and run on this loop it kept
# uvicorn from closing the port until all of it was done. The main thread still waits
# for it (_shutdown_event) before the process exits. One cleanup per launch: keyed by
# the trigger, since an embedded host's next run_server() installs a new one.
with _shutdown_thread_lock:
state = request.app.state
if getattr(state, "_shutdown_trigger", None) is not trigger:
state._shutdown_trigger = trigger
state._shutdown_thread = threading.Thread(
target = trigger, name = "studio-shutdown"
)
state._shutdown_thread.start()
else:
# Fallback when not launched via run_server() (e.g. direct uvicorn)
import signal
import os
os.kill(os.getpid(), signal.SIGTERM)
request.app.state._shutdown_task = asyncio.create_task(_delayed_shutdown())
return {"status": "shutting_down"}
def _get_cached_system_gpu_info(
logger, *, refresh_memory: bool = False
) -> tuple[dict[str, Any], dict[str, Any]]:
"""Return training and inference GPU info with bounded live-probe churn."""
import time
from utils.hardware import (
LIVE_GPU_TELEMETRY_KEYS,
amd_driver_warning_report,
get_backend_visible_gpu_info,
get_cross_vendor_inference_gpu_info,
get_visible_gpu_utilization,
get_vulkan_inference_gpu_info,
)
global _system_gpu_cache
now = time.monotonic()
with _system_gpu_cache_lock:
if not refresh_memory and _system_gpu_cache is not None:
cached_at, cached_gpu_info = _system_gpu_cache
if now - cached_at < _SYSTEM_GPU_CACHE_TTL_SECONDS:
return cached_gpu_info
try:
visibility_info = get_backend_visible_gpu_info() or {"available": False, "devices": []}
except Exception as e:
logger.debug(f"Failed to get GPU visibility info: {e}")
visibility_info = {"available": False, "devices": []}
try:
import contextlib
from utils.hardware import gpu_query
# Already behind a 10 s cache: no stale-while-revalidate on top.
with (
contextlib.nullcontext() if refresh_memory else gpu_query.display_reads(max_stale = 0)
):
utilization_info = get_visible_gpu_utilization() or {"devices": []}
except Exception as e:
logger.debug(f"Failed to get GPU utilization info: {e}")
utilization_info = {"devices": []}
# Device indices are backend-specific. Never overlay CUDA/ROCm metrics
# onto compact Vulkan ordinals merely because both happen to start at 0.
visibility_backend = visibility_info.get("backend")
utilization_backend = utilization_info.get("backend")
metrics_match = (
not visibility_backend
or not utilization_backend
or visibility_backend == utilization_backend
)
util_devices = (
{d.get("index"): d for d in utilization_info.get("devices", [])}
if metrics_match
else {}
)
enriched_devices = []
for dev in visibility_info.get("devices", []):
idx = dev.get("index")
util = util_devices.get(idx, {})
total_vram = util.get("vram_total_gb") or dev.get("memory_total_gb") or 0
# Keep None (usage unknown, e.g. Windows ROCm perf counter) so the UI shows unknown, not 0.
used_vram = util.get("vram_used_gb", dev.get("vram_used_gb"))
reported_free_vram = util.get("vram_free_gb", dev.get("vram_free_gb"))
enriched_dev = dict(dev)
enriched_dev["vram_used_gb"] = used_vram
# A producer that reports free wins: on Apple unified memory free is
# not total - used, so recomputing it here would undo that answer.
enriched_dev["vram_free_gb"] = (
reported_free_vram
if reported_free_vram is not None
else round(total_vram - used_vram, 2)
if total_vram and used_vram is not None
else None
)
enriched_dev["vram_utilization_pct"] = util.get(
"vram_utilization_pct", dev.get("vram_utilization_pct")
)
enriched_dev.update({k: util[k] for k in LIVE_GPU_TELEMETRY_KEYS if k in util})
enriched_devices.append(enriched_dev)
# The tile divides the aggregate by the SUMMED per-device totals, so both must describe the same cards. The
# two probes enumerate independently: visibility drops a device whose mem_get_info raises, the aggregate
# side keeps it, and a device in one and not the other inflates the percentage and floors free at 0 (#7452).
aggregate_basis_matches = metrics_match and {
d.get("index") for d in utilization_info.get("devices", [])
} == {d.get("index") for d in enriched_devices}
try:
from core.inference.llama_cpp import LlamaCppBackend
from utils.hardware import DeviceType, get_device
llama_uses_vulkan = LlamaCppBackend._is_vulkan_backend()
if llama_uses_vulkan:
# The separate inference inventory owns Vulkan ordinals. Keep this false so a failed
# Vulkan probe cannot expose torch indices that llama.cpp reads in another namespace.
gpu_ids_supported = False
else:
# XPU indices cannot yet be applied safely across Level Zero's FLAT and COMPOSITE modes.
# A proven CPU-only llama.cpp build cannot apply a CUDA pin either.
gpu_ids_supported = (
get_device() != DeviceType.XPU and not LlamaCppBackend._backend_lacks_gpu_lib()
)
except Exception as e:
logger.debug(f"Could not resolve gpu_ids support: {e}")
llama_uses_vulkan = False
gpu_ids_supported = True
# The spread also carries `physical_devices` and `mismatch`: GPUs the OS sees that this PyTorch cannot
# open (#8473). They stay their own fields, because `devices` below is the runtime-usable list.
gpu_info = {
**visibility_info,
"available": visibility_info.get("available", False),
"devices": enriched_devices,
"backend": visibility_info.get("backend"),
"gguf_gpu_ids_supported": gpu_ids_supported,
# Host-level used VRAM, for when no counter is attributable to one card
# (#7452). Only the Windows ROCm path sets it; None everywhere else.
"vram_used_gb_aggregate": utilization_info.get("vram_used_gb_aggregate")
if aggregate_basis_matches
else None,
**amd_driver_warning_report(),
}
# Keep inference placement separate on train-capable hosts where a forced Vulkan llama.cpp bundle can
# enumerate a different device set. If Vulkan is installed but its probe fails, retain the unavailable
# Vulkan shape instead of budgeting GPUs llama.cpp cannot use.
if visibility_info.get("backend") == "vulkan":
gpu_info["gguf_gpu_ids_supported"] = bool(enriched_devices)
inference_gpu_info = gpu_info
else:
vulkan_info = get_vulkan_inference_gpu_info()
cross_vendor_info = (
get_cross_vendor_inference_gpu_info() if vulkan_info is None else None
)
if vulkan_info is not None:
inference_gpu_info = {
**vulkan_info,
# Pinnable only once the probe enumerated devices: without ordinals there is nothing to offer.
"gguf_gpu_ids_supported": bool(vulkan_info.get("devices")),
}
elif cross_vendor_info is not None:
# SMI row numbers, not the ordinals a pin is applied in.
inference_gpu_info = {**cross_vendor_info, "gguf_gpu_ids_supported": False}
else:
inference_gpu_info = gpu_info
combined_info = (gpu_info, inference_gpu_info)
_system_gpu_cache = (time.monotonic(), combined_info)
return combined_info
def _probe_dense_quant_supported() -> bool:
"""Whether an ``auto`` request could engage a dense quant on EVERY visible card.
The picker cannot see which card a load lands on, so a mixed host answers for the least capable.
IMPORTS the ML stack, so only ``_refresh_dense_quant_capability`` calls it, and only from the
post-warm worker or a request that already has both modules. Never memoised: the answer
sharpens, since an unprobed scheme counts as usable and a later load can record a kernel
failure in ``_SMOKE_CACHE``."""
try:
from core.inference.diffusion_device import resolve_diffusion_device_target
from core.inference.diffusion_transformer_quant import dense_quant_host_capable
import torch
count = torch.cuda.device_count() if torch.cuda.is_available() else 0
if count <= 1:
return bool(dense_quant_host_capable(resolve_diffusion_device_target()))
# No device scope: cudaSetDevice pins a primary context on every card (CUDA 12).
for ordinal in range(count):
if not dense_quant_host_capable(resolve_diffusion_device_target(ordinal = ordinal)):
return False
return True
except Exception: # noqa: BLE001 -- a capability probe must never fail a status request
return False
def _probe_dense_quant_schemes() -> list[str]:
"""The auto ladder's schemes for this host, best first, on the same ladder and deny list the
loader's ``auto_scheme_candidates`` reads; a mixed host answers with the INTERSECTION. IMPORTS
the ML stack.
The CACHED variant, since a request holding torch reaches this from the polled ``/api/system``:
the load-time helper runs ``_scheme_supported``, which spawns the smoke probe or allocates in
this process. Like the capability bit, it sharpens as loads record verdicts in ``_SMOKE_CACHE``."""
try:
from core.inference.diffusion_device import resolve_diffusion_device_target
from core.inference.diffusion_transformer_quant import auto_scheme_candidates_cached
import torch
count = torch.cuda.device_count() if torch.cuda.is_available() else 0
if count <= 1:
return list(auto_scheme_candidates_cached(resolve_diffusion_device_target()))
common: Optional[list[str]] = None
for ordinal in range(count):
schemes = list(
auto_scheme_candidates_cached(resolve_diffusion_device_target(ordinal = ordinal))
)
common = schemes if common is None else [s for s in common if s in schemes]
return common or []
except Exception: # noqa: BLE001 -- a capability probe must never fail a status request
return []
# Resolved off the polled path: None until the post-warm worker or a request already holding the
# ML stack has answered.
_dense_quant_capability: Optional[bool] = None
_dense_quant_scheme_ladder: list[str] = []
def _refresh_dense_quant_capability() -> bool:
"""Resolve the dense-quant bit and cache it. Imports torch and torchao; never call from a route
that has not already got them."""
global _dense_quant_capability, _dense_quant_scheme_ladder
_dense_quant_capability = _probe_dense_quant_supported()
_dense_quant_scheme_ladder = _probe_dense_quant_schemes() if _dense_quant_capability else []
return _dense_quant_capability
def _dense_quant_supported() -> bool:
"""The dense-quant bit for ``/api/system``, from already-loaded state only.
This route is polled throughout startup, and ``import torch`` and ``import torchao.quantization``
cost ~0.8s each and hold the GIL, the stall ``_await_hardware_detection`` already keeps off this
path. So never import here. The post-warm worker resolves it once the warm has the stack up, and
a request finding both modules loaded refreshes it, so a load's kernel verdict reaches the picker
on the next poll. False before that is honest: the picker renders no fast label, as it does for
an unknown VRAM budget."""
if "torch" in sys.modules and "torchao" in sys.modules:
return _refresh_dense_quant_capability()
return bool(_dense_quant_capability)
# Whether group offload can stream torchao weights (diffusers >= 0.40) on a GPU that can run the INT8
# denoiser. None until resolved off the polled path; the picker offers no streamed tier before then.
_quantised_streaming_capability: Optional[bool] = None
def _refresh_quantised_streaming_capability() -> bool:
"""Resolve and cache the streaming bit. Imports diffusers; never call from the polled route."""
global _quantised_streaming_capability
from core.inference.video import h3_streamed_int8_supported
_quantised_streaming_capability = _probe_quantised_streaming(h3_streamed_int8_supported)
return _quantised_streaming_capability
def _probe_quantised_streaming(supported: Any) -> bool:
"""Every visible CUDA card must qualify: a load may be pinned to any of them, and one that
resolves to float16 keeps bf16 (the same intersection as ``_probe_dense_quant_schemes``)."""
try:
import torch
count = torch.cuda.device_count() if torch.cuda.is_available() else 0
if count <= 1:
return bool(supported())
from core.inference.diffusion_device import resolve_diffusion_device_target
return all(
bool(supported(resolve_diffusion_device_target(ordinal = ordinal)))
for ordinal in range(count)
)
except Exception: # noqa: BLE001 -- an unanswerable probe hides the tier
return False
def _quantised_streaming() -> bool:
"""The streaming bit for ``/api/system``. Resolved here only once a load has already loaded every
module it reads, since a cold warm (UNSLOTH_STUDIO_DISABLE_TORCH_WARM=1) never resolves it.
Needs each module initialised (probing mid-load races the load), not core.inference.video (image loads skip it)."""
if _quantised_streaming_capability is None and all(
(module := sys.modules.get(name)) is not None
and not getattr(getattr(module, "__spec__", None), "_initializing", False)
for name in (
"torch",
"torchao",
"diffusers",
"diffusers.hooks",
"diffusers.hooks.group_offloading",
)
):
try:
return _refresh_quantised_streaming_capability()
except Exception: # noqa: BLE001 -- the picker then keeps the tier hidden
return False
return bool(_quantised_streaming_capability)
def _diffusers_offload_tiers() -> dict:
"""Extra picker fit tiers per curated Diffusers repo (lower-cased id), in the picker's GiB units.
Torch-free; the picker unions them with the catalog's own tiers, so they can only widen."""
try:
from core.inference.video_minimax_h3 import h3_diffusers_fit_tiers
tiers = h3_diffusers_fit_tiers()
except Exception: # noqa: BLE001 -- a picker hint must never break the polled route
return {}
return {"minimaxai/minimax-h3": tiers} if tiers else {}
def _nvfp4_diffusion_enabled() -> bool:
"""Whether image and video generation may offer NVFP4 (``UNSLOTH_NVFP4_DIFFUSION``)."""
try:
from core.inference.diffusion_nvfp4_flag import nvfp4_diffusion_enabled
return nvfp4_diffusion_enabled()
except Exception: # noqa: BLE001 -- a capability read must never fail a status request
return False
def _checkpoint_quant_formats() -> list[str]:
"""FP8 / NVFP4 checkpoint formats the Hub's Recommended filter may list. Torch-free.
Only on a CUDA backend: nvidia-smi still sees the cards under a CPU-only torch or a
CUDA_VISIBLE_DEVICES mask that hides them all, and neither can load these checkpoints."""
try:
from utils.hardware import get_device
from utils.hardware.hardware import _backend_label
from utils.hardware.nvidia import get_checkpoint_quant_formats
if _backend_label(get_device()) != "cuda":
return []
return get_checkpoint_quant_formats()
except Exception: # noqa: BLE001 -- a capability read must never fail a status request
return []
def _dense_quant_schemes() -> list[str]:
"""The scheme ladder for ``/api/system``, a pure read of already-resolved state: the polled route
must never import torch, and the entry beside it refreshed both in one pass."""
return list(_dense_quant_scheme_ladder)
@app.get("/api/system")
def get_system_info(
current_subject: str = Depends(get_current_subject), refresh_memory: bool = False
):
"""Get system information.
Auth-gated: the response (platform, Python/GPU, memory, ML packages) can
fingerprint a host, which matters in -H 0.0.0.0 / Colab / Tauri-relayed
setups where remote callers can reach /api/system.
"""
import platform
import psutil
import os
import time
import logging
from utils.hardware import (
get_device,
export_capability,
video_capability,
cpu_frequency_mhz,
)
from utils.hardware.hardware import _backend_label
logger = logging.getLogger(__name__)
gpu_info, inference_gpu_info = _get_cached_system_gpu_info(
logger, refresh_memory = refresh_memory
)
memory = psutil.virtual_memory()
memory_total = memory.total
memory_available = memory.available
memory_percent = memory.percent
# The picker's RAM tiers compare against available_gb: publish the cgroup-capped view.
try:
from utils import host_memory
_budgets = host_memory.cgroup_memory_budgets()
_headroom_mib = host_memory.cgroup_headroom_mib(_budgets)
_limit_mib = host_memory.cgroup_limit_mib(_budgets)
if _limit_mib is not None:
memory_total = min(memory_total, _limit_mib * 1024**2)
if _headroom_mib is not None:
memory_available = min(memory_available, _headroom_mib * 1024**2)
if _limit_mib is not None or _headroom_mib is not None:
memory_available = min(memory_available, memory_total)
memory_percent = (
round((memory_total - memory_available) / memory_total * 100, 1)
if memory_total
else memory_percent
)
except Exception as e:
logger.debug(f"Failed to read the cgroup memory limit: {e}")
# Corrects psutil's 1000x-too-small Apple Silicon M4+ reading (issue #8519).
cpu_freq_mhz = cpu_frequency_mhz()
try:
disk = psutil.disk_usage(os.path.abspath(os.sep))
except Exception as e:
logger.debug(f"Failed to get disk usage: {e}")
disk = None
from utils.system_disk import cached_models_disk_usage
models_disk = cached_models_disk_usage()
try:
current_process = psutil.Process(os.getpid())
process_used_mb = round(current_process.memory_info().rss / 1024**2)
except Exception as e:
logger.debug(f"Failed to get current process memory: {e}")
process_used_mb = 0
try:
boot_time = psutil.boot_time()
except Exception as e:
logger.debug(f"Failed to get boot time: {e}")
boot_time = None
# Read versions from metadata so a 3s poll never imports heavy ML libs (or 500s on their import errors).
from importlib.metadata import PackageNotFoundError, version as pkg_version
ml_packages = {}
for pkg in ("torch", "transformers"):
try:
ml_packages[pkg] = pkg_version(pkg)
except PackageNotFoundError:
pass
except Exception as e:
logger.debug(f"Failed to read {pkg} version: {e}")
return {
"memory_refreshed": refresh_memory,
"platform": platform.platform(),
"python_version": platform.python_version(),
"device_backend": _backend_label(get_device()),
"cpu_count": psutil.cpu_count(logical = True),
"uptime_seconds": max(0, round(time.time() - boot_time)) if boot_time else None,
"cpu": {
"logical_count": psutil.cpu_count(logical = True),
"physical_count": psutil.cpu_count(logical = False),
"usage_percent": psutil.cpu_percent(interval = None),
"frequency_mhz": cpu_freq_mhz,
},
"memory": {
"total_gb": round(memory_total / 1024**3, 2),
"available_gb": round(memory_available / 1024**3, 2),
"percent_used": memory_percent,
"process_used_mb": process_used_mb,
},
"disk": {
"total_gb": round(disk.total / 1e9, 2) if disk else 0,
"free_gb": round(disk.free / 1e9, 2) if disk else 0,
"percent_used": disk.percent if disk else 0,
},
# Additive: null unless the HF cache sits on another volume (e.g. a symlinked drive).
"models_disk": models_disk,
"gpu": gpu_info,
"inference_gpu": inference_gpu_info,
"ml_packages": ml_packages,
**export_capability(),
# Video capability + reason, same shape. Additive: older clients ignore the extra keys.
**video_capability(),
# One bit cannot tell an Ampere host (int8 only) from an Ada one, nor spot an unsupported
# CUDA card, so the picker gets the scheme list too. The bit resolves first; the list is a
# pure read of that same pass.
"dense_quant_supported": _dense_quant_supported(),
"dense_quant_schemes": _dense_quant_schemes(),
# The streamed MiniMax-H3 tier needs group offload that swaps torchao weights.
"quantised_streaming": _quantised_streaming(),
# Backend-measured offload tiers the picker unions with the catalog's. Additive key.
"diffusers_offload_tiers": _diffusers_offload_tiers(),
# Torch-free env read, safe on this polled route.
"nvfp4_diffusion": _nvfp4_diffusion_enabled(),
# Prequantized FP8 / NVFP4 LLM checkpoints this host runs natively. Additive key.
"checkpoint_quant_formats": _checkpoint_quant_formats(),
}
@app.get("/api/system/disk")
def get_disk_space(
current_subject: str = Depends(get_current_subject),
via_api_key: bool = Depends(authenticated_via_api_key),
):
"""Free space where downloads land. One syscall, and nothing else.
Separate from /api/system because that route enumerates GPUs, reads package metadata and
samples CPU: fine for a screen the user is looking at, far too much to run on the chance
that a disk is filling. The low-disk notice asks for THIS instead, and only when a download
is about to start.
shutil.disk_usage is statvfs on Linux and macOS and GetDiskFreeSpaceExW on Windows, so this
is microseconds on every platform Studio runs on and needs no directory walk. Note that on
Windows it reports the quota available to the CALLING user, which is the number that decides
whether the download fits, so that difference is the correct one.
Measured at the models root rather than the filesystem root: those are different volumes
whenever HF_HUB_CACHE, or the Studio root, sits on another disk, and the free space that
matters is the one the bytes are going to.
Both roots that receive bytes are measured, not just the hub one. HF_XET_CACHE is resolved
independently of HF_HUB_CACHE and holds the Xet chunks every download now streams through,
so the two can sit on different volumes and the wrong one has ample room. The TIGHTEST
reading wins, because the volume that runs out first is the one that stops the download.
Deduplicated by device, so the ordinary install where both live on one disk still costs a
single syscall.
"""
from utils.paths.storage_roots import hf_default_cache_dir, studio_root
# The ACTIVE caches, not the default ones. hf_default_cache_dir() is documented to ignore
# HF_HUB_CACHE and the Models Folder setting, so on a machine that moved its downloads to
# another volume it would answer about ~/.cache/huggingface, which has nothing to do with
# where the next model lands. One SQLite setting read, no walk.
roots = []
try:
from utils.hf_cache_settings import get_hf_cache_paths
paths = get_hf_cache_paths()
roots.extend((paths.hub_cache, paths.xet_cache))
except Exception as exc: # noqa: BLE001 - a settings read must not cost the reading
logger.debug(f"Could not resolve the active caches for the disk reading: {exc}")
def _locate(probe):
"""(first existing ancestor, its device), or None when this root is unreadable.
Two failures that look alike and must not be treated alike. A MISSING directory is
ordinary, since the cache legitimately does not exist yet on a fresh install, and the
volume it would live on is its nearest existing parent. A permission error, an I/O
error or a network mount that is not answering is not missing: climbing past it would
report the parent filesystem's free space for a disk nothing could read, which is the
confidently wrong answer this route exists to avoid. Those leave the root unreadable.
"""
try:
chain = [probe, *probe.parents]
except (OSError, ValueError, RuntimeError):
return None
for candidate in chain:
try:
return candidate, os.stat(candidate).st_dev
except (FileNotFoundError, NotADirectoryError):
continue
except OSError as exc:
logger.debug(f"Cache root {candidate} could not be read: {exc}")
return None
return None
def _read(candidate):
"""The reading for one already-located directory, or None if it cannot be taken."""
try:
usage = shutil.disk_usage(candidate)
except (OSError, ValueError):
return None
return {
"path": str(candidate),
# Decimal GB, matching /api/system, so the two agree on screen.
"total_gb": round(usage.total / 1e9, 2),
"free_gb": round(usage.free / 1e9, 2),
"percent_used": (
round((usage.total - usage.free) / usage.total * 100, 1) if usage.total else 0
),
}
# Locate first, THEN read. Deduplicating after the reading still paid a disk_usage per
# root, so the ordinary install with both caches on one disk was doing two probes to
# answer about one volume, which is the opposite of what the docstring promises and
# costs most on exactly the network mounts this is careful about.
located = []
seen = set()
unreadable = False
for root in roots:
found = _locate(root)
if found is None:
# An ACTIVE destination that cannot be read makes the whole answer unknown, rather
# than quietly leaving the other one to speak for it. Hub and Xet can sit on
# different volumes, so reporting the readable one's free space would be a
# confident number about a disk the download is not filling: the same mistake as
# climbing past an unreadable root, one level up.
unreadable = True
continue
candidate, device = found
if device in seen:
continue
seen.add(device)
located.append(candidate)
readings = [] if unreadable else [r for r in map(_read, located) if r is not None]
if not unreadable and len(readings) != len(located):
# A root that located but would not report is the same situation.
unreadable = True
readings = []
if unreadable:
# Nulls, not zeros, and not a fallback volume either: the caller reads this as
# "could not tell", which neither warns nor blocks a download.
return {"path": None, "total_gb": None, "free_gb": None, "percent_used": None}
if not readings:
# Nothing resolved: fall back to the same places the old reading used.
for probe in (hf_default_cache_dir(), studio_root(), Path(os.path.abspath(os.sep))):
found = _locate(probe)
reading = None if found is None else _read(found[0])
if reading is not None:
readings.append(reading)
break
if readings:
tightest = min(readings, key = lambda r: r["free_gb"])
answer = dict(tightest)
# An API key reaches this route through get_current_subject, and `path` is a raw host
# path naming the service account and its home layout. The repo already draws that
# boundary for the Hub inventory routes; a capacity reading is not a reason to cross
# it, and the low-disk client uses only the numbers.
#
# A managed account is the same disclosure by a different door: its session JWT also
# satisfies get_current_subject, and via_api_key is false for it, so the API-key test
# alone would hand it the owner's home layout. Resources is owner-only and
# /settings/caches sits behind _owner_settings_router, so the path is owner-only here
# too. Redacted rather than 403: requestStart and the poll loop call this for whoever
# is downloading, owner or not, and the numbers are what that caller needs. On a
# single-user install the context defaults to OWNER, so nothing changes.
#
# The INVENTORY redactor, not redact_host_paths: the latter runs _redact with
# redact_ambiguous_path=False and so leaves a field literally named "path" alone,
# which is the whole value here. Verified against the real helper, not assumed.
from utils.account_context import is_owner_context
return redact_inventory_host_paths(
answer, via_api_key = via_api_key or not is_owner_context()
)
# Every probe failed. Nulls, not zeros: diskPressure() reads a zero total as psutil having
# failed and a zero free as a full disk, and this is neither.
return {"path": None, "total_gb": None, "free_gb": None, "percent_used": None}
@app.get("/api/system/gpu-visibility")
async def get_gpu_visibility(current_subject: str = Depends(get_current_subject)):
# Off-loop: get_device() blocks on detection while the warm is still importing torch.
return await asyncio.to_thread(get_backend_visible_gpu_info)
@app.get("/api/system/hardware")
def get_hardware_info(
include_details: bool = Query(False), current_subject: str = Depends(get_current_subject)
):
"""Return GPU name, total VRAM, and key ML package versions.
Gated behind auth alongside /api/system -- same fingerprinting concern.
/api/system/gpu-visibility is also auth-gated.
``include_details`` is for About/diagnostics. The default response stays
cheap for callers that only need the primary GPU summary, like training
method auto-selection. Sync def (not async): hardware/detail probes can
shell out, and FastAPI runs sync endpoints in a threadpool.
"""
from utils.hardware import (
get_gpu_summary,
get_package_versions,
export_capability,
video_capability,
)
body = {
"gpu": get_gpu_summary(),
"versions": get_package_versions(),
# Export capability + torch-aware reason; the Export UI grays out with the message.
**export_capability(),
# Video capability + reason; the Video page shows the message in place of the generator.
**video_capability(),
}
if include_details:
from utils.audio_cpp_update import get_installed_audio_cpp_version
from utils.llama_cpp_update import get_installed_llama_version
# All backend-visible GPUs (respects CUDA_VISIBLE_DEVICES); get_gpu_summary reports only the primary. Sort by
# visible_ordinal: the nvidia-smi path returns physical order, so a reordering CUDA_VISIBLE_DEVICES (e.g.
# "5,3") would mislabel by array index.
devices = get_backend_visible_gpu_info().get("devices", [])
body["gpus"] = [
{"name": d.get("name"), "vram_total_gb": d.get("memory_total_gb")}
for d in sorted(devices, key = lambda d: d.get("visible_ordinal", 0))
]
body["llama_cpp"] = get_installed_llama_version()
body["audio_cpp"] = get_installed_audio_cpp_version()
return body
def _strip_crossorigin(html_bytes: bytes) -> bytes:
"""Remove ``crossorigin`` attributes from script/link tags. Vite's default ``crossorigin`` forces
CORS mode on font loads, which Firefox HTTPS-Only Mode breaks over plain HTTP."""
html = html_bytes.decode("utf-8")
html = _re.sub(r'\s+crossorigin(?:="[^"]*")?', "", html)
return html.encode("utf-8")
def _inject_bootstrap(html_bytes: bytes, app: FastAPI):
"""Inject bootstrap credentials when password change is pending. Returns
``(html_bytes, script_nonce_or_None)``; callers forward the nonce via
``_CSP_SCRIPT_NONCE_HEADER`` so CSP allows the inline script."""
import json as _json
import secrets as _secrets
if not storage.requires_password_change(storage.DEFAULT_ADMIN_USERNAME):
return html_bytes, None
from auth.policy import installation_has_managed_accounts
# A local browser may belong to any account, including a deactivated one.
if installation_has_managed_accounts():
return html_bytes, None
bootstrap_pw = getattr(app.state, "bootstrap_password", None)
if not bootstrap_pw:
return html_bytes, None
payload = _json.dumps(
{
"username": storage.DEFAULT_ADMIN_USERNAME,
"password": bootstrap_pw,
}
)
nonce = _secrets.token_urlsafe(16)
tag = f'<script nonce="{nonce}">window.__UNSLOTH_BOOTSTRAP__={payload}</script>'
html = html_bytes.decode("utf-8")
html = html.replace("</head>", f"{tag}</head>", 1)
return html.encode("utf-8"), nonce
# Also used by the /mcp gate, which cannot import main.
from utils.origin_policy import ( # noqa: E402
canonical_origin as _canonical_origin,
origin_of as _origin_of,
)
# Shared with the routes that must only answer the person at this computer (Settings > Sandbox).
from utils.client_ip import ( # noqa: E402
_PROXIED_CLIENT_HEADERS,
_is_loopback_ip,
is_direct_local_request as _is_local_bootstrap_request,
)
def _is_same_origin_request(request: Request) -> bool:
"""True when Origin is missing or matches request's scheme://host:port. Missing Origin counts as same-origin
(top-level GETs omit it); both sides are canonicalised via :func:`_canonical_origin`, and callers must
emit ``Vary: 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 it doesn't 500.
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
# Colab's proxy is itself a forwarded-header ingress, invisible to the loopback tests, so it is identified
# positively by its own authority: naming one tunnel vendor instead leaves every other relay (ngrok,
# localtunnel, bore, ssh -R) reading as the local browser.
_COLAB_PROXY_HOST_SUFFIXES = ("colab.googleusercontent.com", ".googleusercontent.com", ".colab.dev")
# The proxy relays the notebook owner and only the notebook owner, so it sets x-forwarded-for (which is why
# run.py runs uvicorn with proxy_headers there). The other four mark a relay we cannot attribute to it.
_COLAB_TOLERATED_PROXY_HEADERS = frozenset({"x-forwarded-for"})
def _host_header_is_colab_proxy(host_header: Optional[str]) -> bool:
"""Whether the Host authority belongs to Colab's own port proxy."""
if not host_header:
return False
host = host_header.strip()
if host.startswith("["): # bracketed IPv6 is never a Colab proxy authority
return False
if host.count(":") == 1: # host:port
host = host.split(":", 1)[0]
host = host.lower().rstrip(".")
return host.endswith(_COLAB_PROXY_HOST_SUFFIXES)
def _is_colab_notebook_request(request: Request) -> bool:
"""Allow bootstrap injection through Colab's single-user notebook proxy, and nothing else."""
for header in _PROXIED_CLIENT_HEADERS:
if header in _COLAB_TOLERATED_PROXY_HEADERS:
continue
if request.headers.get(header) is not None:
return False
return _host_header_is_colab_proxy(request.headers.get("host"))
def _should_inject_bootstrap(request: Request) -> bool:
"""Whether to embed the seeded bootstrap password in index.html."""
if not _is_same_origin_request(request):
return False
if _IS_COLAB and _is_colab_notebook_request(request):
return True
return _is_local_bootstrap_request(request)
_IMMUTABLE_ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable"
class ImmutableStaticFiles(StaticFiles):
"""Serve Vite's content-hashed assets without browser revalidation."""
def file_response(
self,
full_path,
stat_result,
scope,
status_code = 200,
):
response = super().file_response(full_path, stat_result, scope, status_code)
response.headers["Cache-Control"] = _IMMUTABLE_ASSET_CACHE_CONTROL
return response
class _AssetGZipMiddleware(GZipMiddleware):
"""Serve range requests uncompressed; gzip + 206 mislabels Content-Range."""
async def __call__(self, scope, receive, send):
if scope["type"] == "http" and any(key == b"range" for key, _ in scope["headers"]):
await self.app(scope, receive, send)
return
await super().__call__(scope, receive, send)
def _is_live_cloudflare_frontend_request(scope, app_state) -> bool:
cloudflare_url = getattr(app_state, "cloudflare_url", None)
headers = dict(scope.get("headers", ()))
if not cloudflare_url or not headers.get(b"cf-connecting-ip"):
return False
try:
expected_host = urlparse(cloudflare_url).hostname
request_host = urlparse(f"//{headers.get(b'host', b'').decode('latin-1')}").hostname
except (UnicodeDecodeError, ValueError):
return False
return bool(expected_host) and request_host == expected_host
def _is_direct_loopback_frontend_request(scope) -> bool:
# Loopback is a browser secure context (mic dictation needs it, #10786); same gate as bootstrap injection.
server = scope.get("server")
if not server or not _is_loopback_ip(server[0]):
return False
return _is_local_bootstrap_request(Request(scope))
def _is_remote_frontend_request(scope, app_state) -> bool:
"""True for a request the desktop backend may answer with its packaged web UI: Cloudflare's own edge, one
of the sockets the runtime LAN listener bound (both keyed on the connection, not a client header), or a
direct unproxied browser on the loopback listener."""
from lan_access import request_on_lan_listener
return (
_is_live_cloudflare_frontend_request(scope, app_state)
or request_on_lan_listener(scope)
or _is_direct_loopback_frontend_request(scope)
)
class _TunnelOnlyFrontend:
def __init__(self, frontend_app, app_state):
self.frontend_app = frontend_app
self.app_state = app_state
async def __call__(self, scope, receive, send):
if scope["type"] != "http" or _is_remote_frontend_request(scope, self.app_state):
await self.frontend_app(scope, receive, send)
return
await Response(status_code = 404)(scope, receive, send)
def setup_frontend(
app: FastAPI,
build_path: Path,
*,
tunnel_only: bool = False,
):
"""Mount frontend static files (optional). ``tunnel_only`` restricts the mount to the callers
`_is_remote_frontend_request` admits."""
if not build_path.exists():
return False
assets_dir = build_path / "assets"
if assets_dir.exists():
assets_app = _AssetGZipMiddleware(
ImmutableStaticFiles(directory = assets_dir),
minimum_size = 1024,
compresslevel = 6,
)
if tunnel_only:
assets_app = _TunnelOnlyFrontend(assets_app, app.state)
app.mount("/assets", assets_app, name = "assets")
def _frontend_request_allowed(request: Request) -> bool:
return not tunnel_only or _is_remote_frontend_request(request.scope, app.state)
def _build_index_response(request: Request) -> Response:
content = (build_path / "index.html").read_bytes()
content = _strip_crossorigin(content)
# Bootstrap pw goes only to a same-origin, direct-loopback client (or Colab's single-user
# proxy): a wildcard bind must not serve it to a LAN or proxied peer. Vary: Origin.
if _should_inject_bootstrap(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):
if not _frontend_request_allowed(request):
return Response(status_code = 404)
return _build_index_response(request)
@app.get("/{full_path:path}")
async def serve_frontend(request: Request, full_path: str):
# Unknown API paths: raise a real 404 so the api_errors handlers render the right envelope
# for /v1/* ({"detail": ...} for /api/*). The request path is "/" + full_path.
if full_path in {"api", "v1"} or full_path.startswith(("api/", "v1/")):
raise HTTPException(status_code = 404, detail = "API endpoint not found")
# MCP clients probe these before connecting; the app shell with a 200 reads as an OAuth server, and
# they would start a login Unsloth Studio does not offer instead of sending the API key.
if full_path.startswith(".well-known/oauth-"):
raise HTTPException(status_code = 404, detail = "Not Found")
if not _frontend_request_allowed(request):
return Response(status_code = 404)
file_path = (build_path / full_path).resolve()
if not file_path.is_relative_to(build_path.resolve()):
return Response(status_code = 403)
if file_path.is_file():
return FileResponse(file_path)
# Last, so a real asset always wins: an engine endpoint Studio does not serve must 404 rather than render the
# app shell, which reads as "supported" to a client that checks the status before the body. Deliberately
# after the file lookup, so a build that ever ships one of these names still serves it.
if is_engine_probe_path(full_path):
raise HTTPException(status_code = 404, detail = "API endpoint not found")
# Serve index.html as bytes - avoids Content-Length mismatch
return _build_index_response(request)
# The catch-all above is what 404s a GET probe. The lifespan reads this to decide whether the engine
# paths still need their own GET denial.
app.state.frontend_mounted = True
return True