unsloth/studio/backend/main.py
Daniel Han 100b8857f2
Fix Studio crash on Anaconda/conda-forge Python (#4484)
* Fix Studio crash on Anaconda Python due to platform._sys_version() parse failure

Anaconda and conda-forge modify sys.version to include distributor
metadata between pipe characters, e.g.:

    3.12.4 | packaged by Anaconda, Inc. | (main, ...) [MSC v.1929 ...]

Python's platform._sys_version() has a hardcoded regex that cannot
parse this format, raising ValueError. CPython closed this as "not
planned" (cpython#102396) since Anaconda modified the binary.

This breaks the import chain: run.py -> structlog -> rich -> attrs,
which calls platform.python_implementation() at module scope.

Fix: before any library imports, strip the pipe segments, parse the
cleaned version string via the standard parser, and cache the result
under the original sys.version key so all subsequent platform calls
hit the cache.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Add defensive fallback for unpaired pipe edge cases in version patch

Address Gemini review suggestion: if the paired-pipe regex leaves
residual pipes (hypothetical single-pipe distributor metadata), fall
back to extracting the version number and the parenthesized build
info directly. Wrap the entire patch in try/except so unexpected
version string formats degrade gracefully instead of crashing the
patch itself.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Refactor into shared _platform_compat module, cover colab.py entrypoint

Address reviewer feedback:

1. Extract the Anaconda/conda-forge sys.version fix into a shared
   _platform_compat.py module that wraps platform._sys_version() with
   a retry-on-ValueError fallback. This is more robust than cache-seeding
   because it handles all future platform._sys_version() calls, not just
   the first one.

2. Import the fix from both run.py and colab.py entrypoints, so Studio
   no longer crashes on Anaconda Python regardless of the launch path.

3. The wrapper is idempotent (guarded by a flag) and handles edge cases:
   paired pipes (Anaconda, conda-forge), unpaired pipes (hypothetical),
   and standard CPython strings (no-op since ValueError is never raised).

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Replace monkey-patch with cache-prime, fix colab.py duplicate sys.path, cover main.py

- Rewrite _platform_compat.py: replace function-wrapping monkey-patch with
  one-shot cache seed (_seed_sys_version_cache). Parses cleaned sys.version
  once and seeds platform._sys_version_cache so the stdlib parser never sees
  the problematic Anaconda/conda-forge pipe-delimited string. No function
  replacement, no idempotency flag, no reload edge cases.

- colab.py: remove duplicate backend_path sys.path insertion after
  _bootstrap_studio_venv(). The early insertion (before _platform_compat
  import) already covers it. This also fixes backend/ ending up behind
  venv site-packages in sys.path ordering.

- run.py: move PYTHONWARNINGS=ignore before _platform_compat import to
  preserve original intent of suppressing warnings early.

- main.py: add sys.path + _platform_compat import before route imports,
  covering the direct `uvicorn main:app` launch path.

- Add test_platform_compat.py with 7 tests covering Anaconda, conda-forge,
  and standard CPython version strings, plus the loggers import chain.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* Remove test_platform_compat.py from PR

* Handle Format B conda-forge version strings with duplicate paren groups

Some conda-forge builds produce sys.version with the build info both
before and after the pipe label (e.g. "3.9.7 (default, ...) | packaged
by conda-forge | (default, ...) \n[GCC 7.5.0]"). After stripping the
pipe segment, two consecutive (...) groups remain, which still fails
platform._sys_version(). Add a second regex pass to drop the duplicate
paren group.

* Guard _sys_version call with try/except to avoid making things worse

If the cleaned version string is still unparseable by the stdlib regex
(e.g. nested parens, exotic multi-pipe formats), silently give up
instead of letting ValueError propagate at import time -- which would
be a worse crash than the original deferred one.

---------

Co-authored-by: Daniel Han <danielhanchen@users.noreply.github.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
2026-03-22 05:36:55 -07:00

360 lines
12 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
"""
Main FastAPI application for Unsloth UI Backend
"""
import os
import sys
from pathlib import Path as _Path
# Suppress annoying C-level dependency warnings globally
os.environ["PYTHONWARNINGS"] = "ignore"
# Ensure backend dir is on sys.path so _platform_compat is importable when
# main.py is launched directly (e.g. `uvicorn main:app`).
_backend_dir = str(_Path(__file__).parent)
if _backend_dir not in sys.path:
sys.path.insert(0, _backend_dir)
# Fix for Anaconda/conda-forge Python: seed platform._sys_version_cache before
# any library imports that trigger attrs -> rich -> structlog -> platform crash.
# See: https://github.com/python/cpython/issues/102396
import _platform_compat # noqa: F401
import shutil
import warnings
from contextlib import asynccontextmanager
# Suppress annoying dependency warnings in production
if os.getenv("ENVIRONMENT_TYPE", "production") == "production":
warnings.filterwarnings("ignore")
# Alternatively, you can be more specific:
# warnings.filterwarnings("ignore", category=DeprecationWarning)
# warnings.filterwarnings("ignore", module="triton.*")
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse, HTMLResponse, Response
from pathlib import Path
from datetime import datetime
# Import routers
from routes import (
auth_router,
data_recipe_router,
datasets_router,
export_router,
inference_router,
models_router,
training_router,
)
from auth import storage
from utils.hardware import detect_hardware, get_device, DeviceType
import utils.hardware.hardware as _hw_module
from utils.cache_cleanup import clear_unsloth_compiled_cache
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Startup: detect hardware, seed default admin if needed. Shutdown: clean up compiled cache."""
# Clean up any stale compiled cache from previous runs
clear_unsloth_compiled_cache()
# Remove stale .venv_overlay from previous versions — no longer used.
# Version switching now uses .venv_t5/ (pre-installed by setup.sh).
overlay_dir = Path(__file__).resolve().parent.parent.parent / ".venv_overlay"
if overlay_dir.is_dir():
shutil.rmtree(overlay_dir, ignore_errors = True)
# Detect hardware first — sets DEVICE global used everywhere
detect_hardware()
# Pre-cache the helper GGUF model for LLM-assisted dataset detection.
# Runs in a background thread so it doesn't block server startup.
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).start()
if storage.ensure_default_admin():
bootstrap_pw = storage.get_bootstrap_password()
app.state.bootstrap_password = bootstrap_pw
print("\n" + "=" * 60)
print("DEFAULT ADMIN ACCOUNT CREATED")
print(
"Sign in with the seeded credentials and change the password immediately:\n"
)
print(f" username: {storage.DEFAULT_ADMIN_USERNAME}")
print(f" password: {bootstrap_pw}\n")
print("=" * 60 + "\n")
else:
app.state.bootstrap_password = storage.get_bootstrap_password()
yield
# Cleanup
_hw_module.DEVICE = None
clear_unsloth_compiled_cache()
# Create FastAPI app
app = FastAPI(
title = "Unsloth UI Backend",
version = "1.0.0",
description = "Backend API for Unsloth UI - Training and Model Management",
lifespan = lifespan,
)
# Initialize structured logging
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)
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins = ["*"], # In production, specify allowed origins
allow_credentials = True,
allow_methods = ["*"],
allow_headers = ["*"],
)
# ============ Register API Routes ============
# Register routers
app.include_router(auth_router, prefix = "/api/auth", tags = ["auth"])
app.include_router(training_router, prefix = "/api/train", tags = ["training"])
app.include_router(models_router, prefix = "/api/models", tags = ["models"])
app.include_router(inference_router, prefix = "/api/inference", tags = ["inference"])
# OpenAI-compatible endpoints: mount the same inference router at /v1
# so external tools (Open WebUI, SillyTavern, etc.) can use the
# standard /v1/chat/completions path.
app.include_router(inference_router, prefix = "/v1", tags = ["openai-compat"])
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(export_router, prefix = "/api/export", tags = ["export"])
# ============ Health and System Endpoints ============
@app.get("/api/health")
async def health_check():
"""Health check endpoint"""
platform_map = {"darwin": "mac", "win32": "windows", "linux": "linux"}
device_type = platform_map.get(sys.platform, sys.platform)
return {
"status": "healthy",
"timestamp": datetime.now().isoformat(),
"service": "Unsloth UI Backend",
"device_type": device_type,
"chat_only": _hw_module.CHAT_ONLY,
}
@app.get("/api/system")
async def get_system_info():
"""Get system information"""
import platform
import subprocess
import psutil
from utils.hardware import get_device, get_gpu_memory_info, DeviceType
# GPU Info — query nvidia-smi for physical GPUs, filtered by
# CUDA_VISIBLE_DEVICES when set (the frontend uses this for GGUF
# fit estimation and llama-server respects CVD too).
import os
gpu_info: dict = {"available": False, "devices": []}
device = get_device()
if device == DeviceType.CUDA:
# Parse CUDA_VISIBLE_DEVICES allowlist
allowed_indices = None
cvd = os.environ.get("CUDA_VISIBLE_DEVICES")
if cvd is not None and cvd.strip():
try:
allowed_indices = set(int(x.strip()) for x in cvd.split(","))
except ValueError:
pass # Non-numeric (e.g. GPU-uuid), show all
try:
result = subprocess.run(
[
"nvidia-smi",
"--query-gpu=index,name,memory.total",
"--format=csv,noheader,nounits",
],
capture_output = True,
text = True,
timeout = 10,
)
if result.returncode == 0:
for line in result.stdout.strip().splitlines():
parts = [p.strip() for p in line.split(",")]
if len(parts) == 3:
idx = int(parts[0])
if allowed_indices is not None and idx not in allowed_indices:
continue
gpu_info["devices"].append(
{
"index": idx,
"name": parts[1],
"memory_total_gb": round(int(parts[2]) / 1024, 2),
}
)
gpu_info["available"] = len(gpu_info["devices"]) > 0
except Exception:
pass
# Fallback to torch-based single-GPU detection
if not gpu_info["available"]:
mem_info = get_gpu_memory_info()
if mem_info.get("available"):
gpu_info["available"] = True
gpu_info["devices"].append(
{
"index": mem_info.get("device", 0),
"name": mem_info.get("device_name", "Unknown"),
"memory_total_gb": round(mem_info.get("total_gb", 0), 2),
}
)
# CPU & Memory
memory = psutil.virtual_memory()
return {
"platform": platform.platform(),
"python_version": platform.python_version(),
"device_backend": get_device().value,
"cpu_count": psutil.cpu_count(),
"memory": {
"total_gb": round(memory.total / 1e9, 2),
"available_gb": round(memory.available / 1e9, 2),
"percent_used": memory.percent,
},
"gpu": gpu_info,
}
@app.get("/api/system/hardware")
async def get_hardware_info():
"""Return GPU name, total VRAM, and key ML package versions."""
from utils.hardware import get_gpu_summary, get_package_versions
return {
"gpu": get_gpu_summary(),
"versions": get_package_versions(),
}
# ============ Serve Frontend (Optional) ============
def _strip_crossorigin(html_bytes: bytes) -> bytes:
"""Remove ``crossorigin`` attributes from script/link tags.
Vite adds ``crossorigin`` by default which forces CORS mode on font
subresource loads. When Studio is served over plain HTTP, Firefox
HTTPS-Only Mode does not exempt CORS font requests -- causing all
@font-face downloads to fail silently. Stripping the attribute
makes them regular same-origin fetches that work on any protocol.
"""
import re as _re
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) -> bytes:
"""Inject bootstrap credentials into HTML when password change is required.
The script tag is only injected while the default admin account still
has ``must_change_password=True``. Once the user changes the password
the HTML is served clean — no credentials leak.
"""
import json as _json
if not storage.requires_password_change(storage.DEFAULT_ADMIN_USERNAME):
return html_bytes
bootstrap_pw = getattr(app.state, "bootstrap_password", None)
if not bootstrap_pw:
return html_bytes
payload = _json.dumps(
{
"username": storage.DEFAULT_ADMIN_USERNAME,
"password": bootstrap_pw,
}
)
tag = f"<script>window.__UNSLOTH_BOOTSTRAP__={payload}</script>"
html = html_bytes.decode("utf-8")
html = html.replace("</head>", f"{tag}</head>", 1)
return html.encode("utf-8")
def setup_frontend(app: FastAPI, build_path: Path):
"""Mount frontend static files (optional)"""
if not build_path.exists():
return False
# Mount assets
assets_dir = build_path / "assets"
if assets_dir.exists():
app.mount("/assets", StaticFiles(directory = assets_dir), name = "assets")
@app.get("/")
async def serve_root():
content = (build_path / "index.html").read_bytes()
content = _strip_crossorigin(content)
content = _inject_bootstrap(content, app)
return Response(
content = content,
media_type = "text/html",
headers = {"Cache-Control": "no-cache, no-store, must-revalidate"},
)
@app.get("/{full_path:path}")
async def serve_frontend(full_path: str):
if full_path.startswith("api"):
return {"error": "API endpoint not found"}
file_path = (build_path / full_path).resolve()
# Block path traversal — ensure resolved path stays inside build_path
if not file_path.is_relative_to(build_path.resolve()):
return Response(status_code = 403)
if file_path.is_file():
return FileResponse(file_path)
# Serve index.html as bytes — avoids Content-Length mismatch
content = (build_path / "index.html").read_bytes()
content = _strip_crossorigin(content)
content = _inject_bootstrap(content, app)
return Response(
content = content,
media_type = "text/html",
headers = {"Cache-Control": "no-cache, no-store, must-revalidate"},
)
return True