unsloth/studio/backend/routes/preview.py
Nilay e5cf956601
Studio: shareable per-checkpoint preview links (#6486)
* checkpoint preview endpoint

* harden new preview endpoints

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

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

* address review

* Studio preview: pin adapter, guard streaming submit, robust copy-link

Harden the public per-checkpoint preview surface:

- Pin use_adapter=True in the preview payload sanitizer. Otherwise an
  unauthenticated /p caller can POST use_adapter=false, which calls
  disable_adapter_layers() on the shared in-memory model without restoring
  it; since load_model skips reloads for the same checkpoint, every later
  visitor (the page never sends the field) keeps getting base-model output
  instead of the fine-tuned checkpoint. Forcing it on also re-enables a
  previously disabled adapter and no-ops on merged checkpoints.
- Ignore preview-page submits while a response is streaming. The send
  button was disabled but the Enter handler still called requestSubmit(),
  so a second request could start before the first reply landed in msgs and
  reorder the chat history. Both the keydown and submit handlers now honor
  the disabled button.
- Keep the cloudflare-URL polling loop alive across transient startup fetch
  errors instead of letting one rejection halt it.
- Build the copy-link from a backend preview_ref (output dir relative to
  outputs_root, gated on previewability and the two-segment /p route limit)
  so a nested output dir no longer copies a basename-only link that 404s.
  Expose preview_ref on training run summaries.

Add route-level security tests (path traversal, payload sanitization,
asset containment, CSP header, HTML title escaping, streaming lock held
until drained) and preview_ref unit tests.

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

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

* Studio preview: Safari-safe submit and adapter pin only for LoRA

Follow-ups from cross-browser and route simulations:

- Preview page: send the message from a shared send() helper called by both
  the form submit and the Enter key, instead of form.requestSubmit(). The
  latter throws on Safari < 16 and older iOS, which broke Enter-to-send there.
  Verified across Chromium, Firefox and WebKit with Playwright.
- Only pin use_adapter=True when the resolved checkpoint is a LoRA adapter
  (adapter_config.json present); for a merged checkpoint strip it to None.
  A merged model has no adapter to toggle, so forcing it on only produced a
  per-request "not a PeftModel" warning. The cross-request base-model
  contamination fix still holds for LoRA previews.

Add a merged-checkpoint test asserting use_adapter is stripped to None.

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

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

* Studio preview: trim verbose comments

Tighten comments across the preview routes, page, checkpoint helpers, and tests
to short single-line notes; drop ones that just restate the code. No behavior
change (verified comment/docstring-only with comment_tools.py check).

* Harden preview routes for PR #6486

- Return a generic 400 detail on a rejected preview path so the public /p
  route never echoes the absolute install path (the real reason is logged
  server-side instead).
- Strip confirm_tool_calls, session_id and rag_scope in the preview payload
  sanitizer so the public surface stays inert regardless of the tool gate.
- Use Path.is_relative_to for the asset containment check, matching the rest
  of the codebase.
- Add img-src 'self' and font-src 'self' to the preview page CSP.
- Preview page: on a mid-stream error keep the streamed text, flag the break,
  and restore the prompt so the user can retry; drop the unused --font-sans var.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: Lee Jackson <130007945+Imagineer99@users.noreply.github.com>
Co-authored-by: Daniel Han <danielhanchen@gmail.com>
2026-06-24 06:31:53 -07:00

196 lines
7 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
"""Per-checkpoint preview endpoints: /p/{run}[/{checkpoint}]/v1/..."""
from __future__ import annotations
import asyncio
import html
from pathlib import Path
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import FileResponse, HTMLResponse, StreamingResponse
from loggers import get_logger
from auth.authentication import get_current_subject
from auth.storage import DEFAULT_ADMIN_USERNAME
from models.inference import ChatCompletionRequest, LoadRequest
from routes.inference import load_model, openai_chat_completions
from state.tool_policy import tools_force_disabled
from utils.models.checkpoints import list_preview_targets, resolve_preview_checkpoint
logger = get_logger(__name__)
router = APIRouter()
# Public (no key); resolve_preview_checkpoint pins `run` under outputs_root.
# One model loads at a time, so serialize load+generate across previews.
_preview_lock = asyncio.Lock()
def _resolve_or_4xx(run: str, checkpoint: str | None):
try:
return resolve_preview_checkpoint(run, checkpoint)
except ValueError as exc:
# Detail can carry the absolute install path on a symlink escape; log it,
# return a generic message on this public route.
logger.warning("preview path rejected: %s", exc)
raise HTTPException(status_code = 400, detail = "Invalid run or checkpoint")
except FileNotFoundError as exc:
raise HTTPException(status_code = 404, detail = str(exc))
def _sanitize_preview_payload(
payload: ChatCompletionRequest, is_lora: bool
) -> ChatCompletionRequest:
# Public surface: strip tools/MCP + provider routing (no host code / open proxy).
# Normalize use_adapter (never trust the caller): pin True for LoRA, None for
# merged. _apply_adapter_state mutates the shared model without restoring, so an
# unpinned `false` would persist to later visitors who omit the field.
return payload.model_copy(
update = {
"tools": None,
"enable_tools": False,
"enabled_tools": None,
"mcp_enabled": False,
"bypass_permissions": False,
"confirm_tool_calls": False,
"session_id": None,
"rag_scope": None,
"openai_code_exec_container_id": None,
"anthropic_code_exec_container_id": None,
"provider_id": None,
"provider_type": None,
"external_model": None,
"encrypted_api_key": None,
"provider_base_url": None,
"use_adapter": True if is_lora else None,
}
)
async def _unlock_after(body_iterator):
# Hold the lock until the stream drains so another checkpoint can't swap mid-stream.
try:
async for chunk in body_iterator:
yield chunk
finally:
_preview_lock.release()
async def _serve_chat(
run: str, checkpoint: str | None, payload: ChatCompletionRequest, request: Request
):
path = _resolve_or_4xx(run, checkpoint)
is_lora = (path / "adapter_config.json").exists()
payload = _sanitize_preview_payload(payload, is_lora)
await _preview_lock.acquire()
keep_locked = False
try:
await load_model(LoadRequest(model_path = str(path)), request, DEFAULT_ADMIN_USERNAME)
# Beats a process-wide `--enable-tools` (enable_tools=False alone wouldn't).
with tools_force_disabled():
response = await openai_chat_completions(payload, request, DEFAULT_ADMIN_USERNAME)
if isinstance(response, StreamingResponse):
response.body_iterator = _unlock_after(response.body_iterator)
keep_locked = True
return response
finally:
if not keep_locked:
_preview_lock.release()
@router.get("")
async def list_previews(request: Request, current_subject: str = Depends(get_current_subject)):
base = str(request.base_url)
previews = []
for target in list_preview_targets():
ref = quote(target["ref"], safe = "/")
previews.append({**target, "url": f"{base}p/{ref}/v1"})
return {"object": "list", "data": previews}
@router.post("/{run}/v1/chat/completions")
async def preview_chat_latest(run: str, payload: ChatCompletionRequest, request: Request):
return await _serve_chat(run, None, payload, request)
@router.post("/{run}/{checkpoint}/v1/chat/completions")
async def preview_chat_checkpoint(
run: str, checkpoint: str, payload: ChatCompletionRequest, request: Request
):
return await _serve_chat(run, checkpoint, payload, request)
def _models_response(run: str, checkpoint: str | None):
path = _resolve_or_4xx(run, checkpoint)
model_id = run if not checkpoint else f"{run}/{checkpoint}"
return {
"object": "list",
"data": [
{
"id": model_id,
"object": "model",
"created": int(path.stat().st_mtime),
"owned_by": "unsloth-studio",
}
],
}
@router.get("/{run}/v1/models")
async def preview_models_latest(run: str):
return _models_response(run, None)
@router.get("/{run}/{checkpoint}/v1/models")
async def preview_models_checkpoint(run: str, checkpoint: str):
return _models_response(run, checkpoint)
# Serve logo/fonts here too: the SPA static mount is absent in --api-only (Tauri).
_FRONTEND_DIST = (Path(__file__).resolve().parents[2] / "frontend" / "dist").resolve()
_PREVIEW_ASSET_MEDIA_TYPES = {
".png": "image/png",
".woff": "font/woff",
".woff2": "font/woff2",
}
@router.get("/_assets/{asset_path:path}")
async def preview_asset(asset_path: str):
target = (_FRONTEND_DIST / asset_path).resolve()
media_type = _PREVIEW_ASSET_MEDIA_TYPES.get(target.suffix.lower())
if media_type is None or not target.is_relative_to(_FRONTEND_DIST) or not target.is_file():
raise HTTPException(status_code = 404, detail = "Not found")
return FileResponse(target, media_type = media_type)
# Self-contained public page; only the title is interpolated.
_PREVIEW_PAGE_HTML = (
Path(__file__).resolve().parent.parent / "assets" / "preview_page.html"
).read_text(encoding = "utf-8")
_PREVIEW_PAGE_CSP = (
"default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; "
"img-src 'self'; font-src 'self'; connect-src 'self'; base-uri 'none'"
)
def _preview_page(run: str, checkpoint: str | None) -> HTMLResponse:
_resolve_or_4xx(run, checkpoint)
title = run if not checkpoint else f"{run}/{checkpoint}"
page = _PREVIEW_PAGE_HTML.replace("__TITLE__", html.escape(title))
return HTMLResponse(page, headers = {"Content-Security-Policy": _PREVIEW_PAGE_CSP})
@router.get("/{run}", response_class = HTMLResponse)
async def preview_page_latest(run: str):
return _preview_page(run, None)
@router.get("/{run}/{checkpoint}", response_class = HTMLResponse)
async def preview_page_checkpoint(run: str, checkpoint: str):
return _preview_page(run, checkpoint)