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>
This commit is contained in:
Nilay 2026-06-24 19:01:53 +05:30 committed by GitHub
commit e5cf956601
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 1206 additions and 10 deletions

View file

@ -57,6 +57,7 @@ studio = [
"backend/requirements/**/*",
"backend/plugins/**/*",
"backend/assets/**/*.jinja",
"backend/assets/**/*.html",
"backend/core/data_recipe/oxc-validator/*.json",
"backend/core/data_recipe/oxc-validator/*.mjs",
]

View file

@ -0,0 +1,398 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>__TITLE__ - Unsloth</title>
<style>
@font-face {
font-family: "Hellix";
src: url("/p/_assets/fonts/Hellix-Medium.woff") format("woff");
font-weight: 500;
font-display: swap;
}
@font-face {
font-family: "Hellix";
src: url("/p/_assets/fonts/Hellix-SemiBold.woff2") format("woff2");
font-weight: 600;
font-display: swap;
}
:root {
color-scheme: light dark;
--bg: #fefefd;
--fg: #0d0d0d;
--muted: #858279;
--border: #ececec;
--user-bubble: #f5f5f5;
--primary: #17b88b;
--composer-bg: #ffffff;
--composer-shadow: 0 2px 8px -2px rgba(0, 0, 0, 0.16);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #1a1b1e;
--fg: #ececee;
--muted: #96979b;
--border: #3a3d42;
--user-bubble: #2d2e32;
--composer-bg: #2d2e32;
--composer-shadow: none;
}
}
* {
box-sizing: border-box;
}
html,
body {
height: 100%;
}
body {
margin: 0;
background: var(--bg);
color: var(--fg);
display: flex;
flex-direction: column;
font:
15.5px/1.6 "Inter",
"Inter Variable",
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
system-ui,
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.heading {
font-family: "Hellix", "Space Grotesk", system-ui, sans-serif;
}
header {
display: flex;
align-items: center;
gap: 9px;
padding: 14px 20px;
}
header img {
width: 22px;
height: 22px;
border-radius: 50%;
}
.brand {
font-family: "Hellix", "Space Grotesk", system-ui, sans-serif;
font-weight: 600;
font-size: 15px;
}
.model {
margin-left: auto;
max-width: 55%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12.5px;
color: var(--muted);
}
#log {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
padding: 8px 16px 24px;
}
#thread {
width: 100%;
max-width: 46.5rem;
margin: 0 auto;
display: flex;
flex-direction: column;
}
.welcome {
margin: auto;
text-align: center;
padding: 0 16px;
animation: fade 0.25s ease-out;
}
.welcome h1 {
margin: 0;
font-weight: 500;
font-size: 30px;
letter-spacing: -0.02em;
}
.welcome p {
margin: 0.55rem 0 0;
color: var(--muted);
font-size: 14px;
}
.msg {
font-size: 15.5px;
font-weight: 450;
letter-spacing: 0.01em;
word-wrap: break-word;
white-space: pre-wrap;
animation: fade 0.15s ease-out;
}
.user {
align-self: flex-end;
max-width: 80%;
margin-top: 24px;
padding: 10px 16px;
border-radius: 24px;
background: var(--user-bubble);
}
.assistant {
align-self: stretch;
margin-top: 16px;
line-height: 1.75;
}
.dots {
display: inline-flex;
gap: 5px;
align-items: center;
height: 1.6em;
}
.dots i {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--muted);
animation: blink 1.2s infinite;
}
.dots i:nth-child(2) {
animation-delay: 0.18s;
}
.dots i:nth-child(3) {
animation-delay: 0.36s;
}
.composer-wrap {
padding: 6px 16px 16px;
}
form {
width: 100%;
max-width: 46.5rem;
margin: 0 auto;
}
.composer {
display: flex;
align-items: flex-end;
gap: 8px;
padding: 8px 8px 8px 18px;
border-radius: 28px;
background: var(--composer-bg);
box-shadow: var(--composer-shadow);
}
textarea {
flex: 1;
border: 0;
outline: 0;
resize: none;
background: transparent;
color: var(--fg);
font: inherit;
line-height: 1.5;
max-height: 200px;
padding: 8px 0;
}
textarea::placeholder {
color: var(--muted);
}
.send {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border: 0;
border-radius: 50%;
background: var(--primary);
color: #fff;
cursor: pointer;
}
.send:disabled {
opacity: 0.4;
cursor: default;
}
.foot {
margin: 9px auto 0;
max-width: 46.5rem;
text-align: center;
font-size: 11px;
color: var(--muted);
}
@keyframes blink {
0%,
80%,
100% {
opacity: 0.25;
}
40% {
opacity: 1;
}
}
@keyframes fade {
from {
opacity: 0;
transform: translateY(2px);
}
to {
opacity: 1;
transform: none;
}
}
</style>
</head>
<body>
<header>
<img src="/p/_assets/circle-logo-small.png" alt="" /><span class="brand"
>Unsloth</span
><span class="model">__TITLE__</span>
</header>
<main id="log">
<div id="welcome" class="welcome">
<h1 class="heading">Chat with your model</h1>
<p>Fine-tuned with Unsloth</p>
</div>
<div id="thread"></div>
</main>
<div class="composer-wrap">
<form id="f">
<div class="composer">
<textarea
id="i"
rows="1"
autocomplete="off"
placeholder="Message this model..."
></textarea>
<button id="b" class="send" aria-label="Send">
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M12 19V5" />
<path d="M5 12l7-7 7 7" />
</svg>
</button>
</div>
<div class="foot">Served by Unsloth Studio</div>
</form>
</div>
<script>
const base = location.pathname.replace(/\/+$/, "");
const log = document.getElementById("log"),
thread = document.getElementById("thread"),
welcome = document.getElementById("welcome");
const form = document.getElementById("f"),
input = document.getElementById("i"),
btn = document.getElementById("b");
const msgs = [];
const down = () => {
log.scrollTop = log.scrollHeight;
};
function autosize() {
input.style.height = "auto";
input.style.height = Math.min(input.scrollHeight, 200) + "px";
}
input.addEventListener("input", autosize);
input.addEventListener("keydown", (e) => {
if (e.isComposing || e.keyCode === 229) return;
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
// send() (not form.requestSubmit, unsupported on Safari < 16) guards the btn.
send();
}
});
function add(role) {
const d = document.createElement("div");
d.className = "msg " + role;
thread.appendChild(d);
down();
return d;
}
async function send() {
// One path for button + Enter; ignore while a request is in flight.
if (btn.disabled) return;
const content = input.value.trim();
if (!content) return;
if (welcome) welcome.style.display = "none";
input.value = "";
autosize();
btn.disabled = true;
msgs.push({ role: "user", content });
add("user").textContent = content;
const out = add("assistant");
out.innerHTML = '<span class="dots"><i></i><i></i><i></i></span>';
let acc = "";
try {
const r = await fetch(base + "/v1/chat/completions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: "preview",
messages: msgs,
stream: true,
}),
});
if (!r.ok) {
out.textContent =
"Error " + r.status + ": " + (await r.text()).slice(0, 300);
msgs.pop();
input.value = content; // restore the prompt so the user can retry
autosize();
btn.disabled = false;
return;
}
const reader = r.body.getReader(),
dec = new TextDecoder();
let buf = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, i).trim();
buf = buf.slice(i + 1);
if (!line.startsWith("data:")) continue;
const data = line.slice(5).trim();
if (data === "[DONE]") continue;
try {
const j = JSON.parse(data);
const d =
j.choices &&
j.choices[0] &&
j.choices[0].delta &&
j.choices[0].delta.content;
if (d) {
acc += d;
out.textContent = acc;
down();
}
} catch (_) {}
}
}
if (!acc) out.textContent = "";
msgs.push({ role: "assistant", content: acc });
} catch (err) {
// Keep any streamed text, flag the break, restore the prompt for retry.
out.textContent = acc ? acc + "\n\n[connection lost]" : "Network error, please retry.";
msgs.pop();
input.value = content;
autosize();
}
btn.disabled = false;
input.focus();
}
form.addEventListener("submit", (e) => {
e.preventDefault();
send();
});
autosize();
input.focus();
</script>
</body>
</html>

View file

@ -282,6 +282,7 @@ from routes import (
training_router,
)
from routes.llama import router as llama_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,
@ -672,6 +673,7 @@ from utils.upload_limits import ( # noqa: E402
_BODY_PROTECTED_PREFIXES = (
"/v1/chat/completions",
"/v1/completions",
"/p/",
"/api/inference",
"/api/data-recipe",
"/api/datasets",
@ -885,6 +887,7 @@ app.include_router(inference_studio_router, prefix = "/api/inference", tags = ["
# OpenAI-compatible: mount the inference router at /v1 for external tools.
app.include_router(inference_router, prefix = "/v1", 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(settings_router, prefix = "/api/settings", tags = ["settings"])
app.include_router(mcp_servers_router, prefix = "/api/mcp/servers", tags = ["mcp"])

View file

@ -601,6 +601,8 @@ class TrainingRunSummary(BaseModel):
loss_sparkline: Optional[List[float]] = None
can_resume: bool = False
resumed_later: bool = False
has_preview_model: bool = False
preview_ref: Optional[str] = None
class TrainingRunUpdateRequest(BaseModel):

View file

@ -0,0 +1,196 @@
# 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)

View file

@ -27,6 +27,7 @@ from storage.studio_db import (
list_runs,
update_run_display_name,
)
from utils.models.checkpoints import has_preview_model, preview_ref
logger = get_logger(__name__)
@ -42,7 +43,17 @@ async def list_training_runs(
"""List training runs, newest first."""
result = list_runs(limit = limit, offset = offset)
return TrainingRunListResponse(
runs = [TrainingRunSummary(**{**r, "can_resume": can_resume_run(r)}) for r in result["runs"]],
runs = [
TrainingRunSummary(
**{
**r,
"can_resume": can_resume_run(r),
"has_preview_model": has_preview_model(r.get("output_dir")),
"preview_ref": preview_ref(r.get("output_dir")),
}
)
for r in result["runs"]
],
total = result["total"],
)
@ -67,6 +78,8 @@ async def get_training_run_detail(run_id: str, current_subject: str = Depends(ge
**{
**{k: v for k, v in run.items() if k != "config_json"},
"can_resume": can_resume_run(run),
"has_preview_model": has_preview_model(run.get("output_dir")),
"preview_ref": preview_ref(run.get("output_dir")),
}
),
config = config,
@ -98,6 +111,8 @@ async def update_training_run(
**{
**{k: v for k, v in refreshed.items() if k != "config_json"},
"can_resume": can_resume_run(refreshed),
"has_preview_model": has_preview_model(refreshed.get("output_dir")),
"preview_ref": preview_ref(refreshed.get("output_dir")),
}
)

View file

@ -1095,7 +1095,10 @@ def run_server(
app.state.server_port = port if port and port > 0 else None
# Direct (non-tunnel) base for the API panel; resolve 0.0.0.0 to the LAN IP.
if port and port > 0:
_direct_host = _resolve_external_ip() if host == "0.0.0.0" else host
_direct_host = _resolve_external_ip() if host in ("0.0.0.0", "::") else host
# Bracket IPv6 literals so the URL is valid (http://[2405:...]:port).
if ":" in _direct_host and not _direct_host.startswith("["):
_direct_host = f"[{_direct_host}]"
app.state.server_url = f"http://{_direct_host}:{port}"
else:
app.state.server_url = None

View file

@ -10,15 +10,34 @@ Set by `unsloth run` at startup; consulted by the inference route gates.
False -> CLI forced tools off for every request.
"""
from typing import Optional
import contextvars
from contextlib import contextmanager
from typing import Iterator, Optional
_tool_policy: Optional[bool] = None
# Per-request hard-off so public surfaces refuse tools even under a CLI `--enable-tools`.
_force_disabled: contextvars.ContextVar[bool] = contextvars.ContextVar(
"tool_policy_force_disabled", default = False
)
def get_tool_policy() -> Optional[bool]:
if _force_disabled.get():
return False
return _tool_policy
@contextmanager
def tools_force_disabled() -> Iterator[None]:
"""Hard-disable server-side tools for the current async context."""
token = _force_disabled.set(True)
try:
yield
finally:
_force_disabled.reset(token)
def set_tool_policy(value: Optional[bool]) -> None:
if value is not None and not isinstance(value, bool):
raise TypeError(f"tool_policy must be Optional[bool], got {type(value).__name__}")

View file

@ -0,0 +1,134 @@
# 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 json
from pathlib import Path
import sys
import types as _types
import pytest
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
if _BACKEND_DIR not in sys.path:
sys.path.insert(0, _BACKEND_DIR)
_loggers_stub = _types.ModuleType("loggers")
_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name)
sys.modules.setdefault("loggers", _loggers_stub)
from utils.models.checkpoints import (
list_preview_targets,
preview_ref,
resolve_preview_checkpoint,
)
def _make_run(outputs: Path) -> tuple[Path, Path]:
run = outputs / "unsloth_SmolLM-135M_1775412608"
run.mkdir(parents = True)
(run / "adapter_config.json").write_text(
json.dumps({"base_model_name_or_path": "HuggingFaceTB/SmolLM-135M"})
)
ckpt = run / "checkpoint-60"
ckpt.mkdir()
(ckpt / "adapter_config.json").write_text(
json.dumps({"base_model_name_or_path": "HuggingFaceTB/SmolLM-135M"})
)
return run, ckpt
def _point_outputs_root_at(monkeypatch, outputs: Path) -> None:
from utils.paths import storage_roots as _sr
from utils.models import checkpoints as _ckpt
monkeypatch.setattr(_sr, "outputs_root", lambda: outputs)
# checkpoints imported outputs_root by name; patch that alias too (preview_ref uses it).
monkeypatch.setattr(_ckpt, "outputs_root", lambda: outputs)
def test_resolve_main_adapter_and_checkpoint(tmp_path: Path, monkeypatch):
outputs = tmp_path / "outputs"
run, ckpt = _make_run(outputs)
_point_outputs_root_at(monkeypatch, outputs)
assert resolve_preview_checkpoint(run.name) == run
assert resolve_preview_checkpoint(run.name, "checkpoint-60") == ckpt
def test_resolve_missing_raises_not_found(tmp_path: Path, monkeypatch):
outputs = tmp_path / "outputs"
_make_run(outputs)
_point_outputs_root_at(monkeypatch, outputs)
with pytest.raises(FileNotFoundError):
resolve_preview_checkpoint("does-not-exist")
(outputs / "empty").mkdir()
with pytest.raises(FileNotFoundError):
resolve_preview_checkpoint("empty")
def test_resolve_rejects_traversal(tmp_path: Path, monkeypatch):
outputs = tmp_path / "outputs"
_make_run(outputs)
_point_outputs_root_at(monkeypatch, outputs)
with pytest.raises(ValueError):
resolve_preview_checkpoint("..", "etc")
def test_list_preview_targets_flattens_with_latest_flag(tmp_path: Path, monkeypatch):
outputs = tmp_path / "outputs"
run, _ = _make_run(outputs)
_point_outputs_root_at(monkeypatch, outputs)
targets = list_preview_targets(str(outputs))
by_ref = {t["ref"]: t for t in targets}
assert by_ref[run.name]["is_latest"] is True
assert by_ref[run.name]["checkpoint"] is None
assert by_ref[f"{run.name}/checkpoint-60"]["is_latest"] is False
assert by_ref[f"{run.name}/checkpoint-60"]["checkpoint"] == "checkpoint-60"
assert all(t["base_model"] == "HuggingFaceTB/SmolLM-135M" for t in targets)
def test_preview_ref_flat_run_is_basename(tmp_path: Path, monkeypatch):
outputs = tmp_path / "outputs"
run, _ = _make_run(outputs)
_point_outputs_root_at(monkeypatch, outputs)
assert preview_ref(str(run)) == run.name
def test_preview_ref_preserves_one_level_nesting(tmp_path: Path, monkeypatch):
outputs = tmp_path / "outputs"
_point_outputs_root_at(monkeypatch, outputs)
nested = outputs / "experiments" / "run1"
nested.mkdir(parents = True)
(nested / "adapter_config.json").write_text("{}")
# /p route supports run/checkpoint, so a single level of nesting survives.
assert preview_ref(str(nested)) == "experiments/run1"
def test_preview_ref_none_for_unpreviewable_or_too_deep(tmp_path: Path, monkeypatch):
outputs = tmp_path / "outputs"
_point_outputs_root_at(monkeypatch, outputs)
# Missing / no model artifact -> not previewable.
assert preview_ref(None) is None
empty = outputs / "empty"
empty.mkdir(parents = True)
assert preview_ref(str(empty)) is None
# Too deep for the two-segment /p route -> no dead link.
deep = outputs / "a" / "b" / "run"
deep.mkdir(parents = True)
(deep / "adapter_config.json").write_text("{}")
assert preview_ref(str(deep)) is None
# Outside outputs_root -> None.
outside = tmp_path / "elsewhere"
outside.mkdir()
(outside / "adapter_config.json").write_text("{}")
assert preview_ref(str(outside)) is None

View file

@ -0,0 +1,293 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Security smoke for the public /p preview routes.
Exercises the route layer with a real ``preview_router`` while stubbing the
expensive model calls (``load_model`` / ``openai_chat_completions``). Covers the
public-surface guarantees: path-traversal rejection, request sanitization
(tools / provider routing / use_adapter), asset-path containment, the page CSP
header + HTML escaping, and that the preview lock is held until a streaming
response is fully drained.
"""
import asyncio
import json
from pathlib import Path
import sys
import types as _types
import pytest
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
if _BACKEND_DIR not in sys.path:
sys.path.insert(0, _BACKEND_DIR)
# Mirror test_preview.py: the real `loggers` package pulls in heavy handlers.
_loggers_stub = _types.ModuleType("loggers")
_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name)
sys.modules.setdefault("loggers", _loggers_stub)
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from fastapi.testclient import TestClient
import routes.preview as preview
from models.inference import ChatCompletionRequest
def _make_run(outputs: Path, name: str = "demorun") -> Path:
run = outputs / name
run.mkdir(parents = True)
(run / "adapter_config.json").write_text(
json.dumps({"base_model_name_or_path": "HuggingFaceTB/SmolLM-135M"})
)
ckpt = run / "checkpoint-1"
ckpt.mkdir()
(ckpt / "adapter_config.json").write_text("{}")
return run
@pytest.fixture
def captured():
return {}
@pytest.fixture
def client(tmp_path, monkeypatch, captured):
outputs = tmp_path / "outputs"
_make_run(outputs)
# resolve_preview_checkpoint -> resolve_output_dir -> outputs_root().
from utils.paths import storage_roots as _sr
monkeypatch.setattr(_sr, "outputs_root", lambda: outputs)
async def _fake_load_model(load_req, request, subject):
captured["load_path"] = load_req.model_path
return None
async def _fake_chat(payload, request, subject):
captured["payload"] = payload
return {"ok": True}
monkeypatch.setattr(preview, "load_model", _fake_load_model)
monkeypatch.setattr(preview, "openai_chat_completions", _fake_chat)
app = FastAPI()
app.include_router(preview.router, prefix = "/p")
app.dependency_overrides[preview.get_current_subject] = lambda: "admin"
# raise_server_exceptions=False so a 5xx surfaces as a response, not a throw.
return TestClient(app, raise_server_exceptions = False)
# ── Page rendering ────────────────────────────────────────────────────────
def test_page_renders_with_csp(client):
r = client.get("/p/demorun")
assert r.status_code == 200
assert "text/html" in r.headers["content-type"]
csp = r.headers.get("content-security-policy", "")
assert "default-src 'self'" in csp
assert "base-uri 'none'" in csp
def test_page_escapes_title(tmp_path, monkeypatch, captured):
outputs = tmp_path / "outputs"
# Run dir name carries an HTML-special char; the page must escape it.
_make_run(outputs, name = "a<b")
from utils.paths import storage_roots as _sr
monkeypatch.setattr(_sr, "outputs_root", lambda: outputs)
app = FastAPI()
app.include_router(preview.router, prefix = "/p")
c = TestClient(app, raise_server_exceptions = False)
r = c.get("/p/a%3Cb")
assert r.status_code == 200
assert "a<b" not in r.text
assert "a&lt;b" in r.text
def test_models_endpoint_shape(client):
r = client.get("/p/demorun/v1/models")
assert r.status_code == 200
body = r.json()
assert body["object"] == "list"
assert body["data"][0]["id"] == "demorun"
assert body["data"][0]["owned_by"] == "unsloth-studio"
def test_list_previews_builds_urls(client, monkeypatch):
monkeypatch.setattr(
preview,
"list_preview_targets",
lambda: [{"ref": "demorun", "is_latest": True}],
)
r = client.get("/p")
assert r.status_code == 200
data = r.json()["data"]
assert data[0]["url"].endswith("/p/demorun/v1")
# ── Path traversal / containment ────────────────────────────────────────────
@pytest.mark.parametrize(
"path",
[
"/p/..", # parent segment as run
"/p/%2e%2e/etc", # encoded traversal
"/p/..%2f..%2fetc/v1/models", # encoded slash traversal
"/p/does-not-exist", # unknown run
],
)
def test_traversal_and_missing_rejected(client, path):
r = client.get(path)
assert r.status_code in (400, 404), (path, r.status_code)
def test_chat_traversal_rejected(client):
r = client.post(
"/p/..%2f..%2fetc/v1/chat/completions",
json = {"messages": [{"role": "user", "content": "hi"}]},
)
assert r.status_code in (400, 404)
# ── Asset containment ────────────────────────────────────────────────────────
@pytest.mark.parametrize(
"asset",
[
"../../../../etc/passwd", # escapes dist
"secrets.txt", # non-allowlisted suffix
"nope.png", # allowlisted suffix but missing
],
)
def test_asset_path_contained(client, asset):
r = client.get(f"/p/_assets/{asset}")
assert r.status_code == 404
# ── Request sanitization ─────────────────────────────────────────────────────
def test_chat_payload_sanitized(client, captured):
r = client.post(
"/p/demorun/v1/chat/completions",
json = {
"messages": [{"role": "user", "content": "hi"}],
"tools": [{"type": "function", "function": {"name": "rm", "parameters": {}}}],
"enable_tools": True,
"enabled_tools": ["python"],
"mcp_enabled": True,
"bypass_permissions": True,
"provider_id": "p1",
"provider_type": "custom",
"provider_base_url": "http://evil.example/v1",
"external_model": "gpt-4o",
"use_adapter": False,
"confirm_tool_calls": True,
"session_id": "abc",
"rag_scope": {"project_id": "x"},
},
)
assert r.status_code == 200
p = captured["payload"]
assert isinstance(p, ChatCompletionRequest)
# Tools / code-exec off.
assert p.tools is None
assert p.enable_tools is False
assert p.enabled_tools is None
assert p.mcp_enabled is False
assert p.bypass_permissions is False
# Tool-loop levers neutralized regardless of the tool gate.
assert p.confirm_tool_calls is False
assert p.session_id is None
assert p.rag_scope is None
# Provider routing stripped so /p can't proxy an arbitrary endpoint.
assert p.provider_id is None
assert p.provider_type is None
assert p.provider_base_url is None
assert p.external_model is None
# Adapter pinned on for LoRA: a caller can't flip the shared backend to base.
assert p.use_adapter is True
# Loads the resolved checkpoint dir, not an attacker-supplied path.
assert captured["load_path"].endswith("demorun")
def test_merged_checkpoint_strips_use_adapter(tmp_path, monkeypatch, captured):
# Merged (non-LoRA) checkpoint: no adapter to toggle, so use_adapter -> None.
outputs = tmp_path / "outputs"
merged = outputs / "mergedrun"
merged.mkdir(parents = True)
(merged / "config.json").write_text(json.dumps({"_name_or_path": "some/base"}))
from utils.paths import storage_roots as _sr
monkeypatch.setattr(_sr, "outputs_root", lambda: outputs)
async def _fake_load(load_req, request, subject):
return None
async def _fake_chat(payload, request, subject):
captured["payload"] = payload
return {"ok": True}
monkeypatch.setattr(preview, "load_model", _fake_load)
monkeypatch.setattr(preview, "openai_chat_completions", _fake_chat)
app = FastAPI()
app.include_router(preview.router, prefix = "/p")
c = TestClient(app, raise_server_exceptions = False)
r = c.post(
"/p/mergedrun/v1/chat/completions",
json = {"messages": [{"role": "user", "content": "hi"}], "use_adapter": False},
)
assert r.status_code == 200
assert captured["payload"].use_adapter is None
# ── Streaming lock lifetime ──────────────────────────────────────────────────
def test_streaming_holds_lock_until_drained(tmp_path, monkeypatch, captured):
outputs = tmp_path / "outputs"
_make_run(outputs)
from utils.paths import storage_roots as _sr
monkeypatch.setattr(_sr, "outputs_root", lambda: outputs)
async def _fake_load_model(load_req, request, subject):
return None
async def _gen():
yield b"data: {}\n\n"
yield b"data: [DONE]\n\n"
async def _fake_chat(payload, request, subject):
return StreamingResponse(_gen())
monkeypatch.setattr(preview, "load_model", _fake_load_model)
monkeypatch.setattr(preview, "openai_chat_completions", _fake_chat)
async def _run():
assert not preview._preview_lock.locked()
payload = ChatCompletionRequest(messages = [{"role": "user", "content": "hi"}])
resp = await preview._serve_chat("demorun", None, payload, request = None)
# Lock must still be held: a second checkpoint must not swap the backend
# mid-stream.
assert preview._preview_lock.locked()
chunks = [c async for c in resp.body_iterator]
# Released only after the stream fully drains.
assert not preview._preview_lock.locked()
return chunks
chunks = asyncio.run(_run())
assert any(b"[DONE]" in c for c in chunks)
assert not preview._preview_lock.locked()

View file

@ -125,6 +125,12 @@ def is_anthropic_path(path: str) -> bool:
return path.startswith("/v1/messages")
def wants_api_error_envelope(path: str) -> bool:
"""True for the OpenAI/Anthropic-compatible surfaces: the ``/v1/*`` mount and
the preview ``/p/<run>[/<ckpt>]/v1/*`` mount."""
return path.startswith("/v1/") or (path.startswith("/p/") and "/v1/" in path)
def error_body_for_path(
path,
message,
@ -183,15 +189,16 @@ def _summarize_validation_errors(errors) -> tuple:
def install_api_error_handlers(app) -> None:
"""Register validation + HTTPException handlers that emit ``/v1/*`` envelopes.
Both handlers are global but only transform responses for paths starting with
``/v1/``. Non-``/v1/`` paths reproduce FastAPI's default ``{"detail": ...}``
behavior exactly so the Studio frontend keeps working.
Both handlers are global but only transform responses for OpenAI/Anthropic-
compatible surfaces (see :func:`wants_api_error_envelope`: the ``/v1/*`` mount
and the preview ``/p/.../v1/*`` mount). Every other path reproduces FastAPI's
default ``{"detail": ...}`` behavior exactly so the Studio frontend keeps working.
"""
@app.exception_handler(RequestValidationError)
async def _handle_validation_error(request, exc):
path = request.url.path
if path.startswith("/v1/"):
if wants_api_error_envelope(path):
summary, param = _summarize_validation_errors(exc.errors())
return JSONResponse(
status_code = 400,
@ -211,7 +218,7 @@ def install_api_error_handlers(app) -> None:
# default http_exception_handler, which returns a bodiless Response.
if not is_body_allowed_for_status_code(exc.status_code):
return Response(status_code = exc.status_code, headers = headers)
if path.startswith("/v1/"):
if wants_api_error_envelope(path):
detail = exc.detail
# Already a fully-formed envelope: pass through untouched.
if isinstance(detail, dict) and ("error" in detail or detail.get("type") == "error"):

View file

@ -159,3 +159,64 @@ def scan_checkpoints(
except Exception as e:
logger.error(f"Error scanning checkpoints: {e}")
return []
def _is_model_dir(path: Path) -> bool:
return (path / "config.json").exists() or (path / "adapter_config.json").exists()
def has_preview_model(output_dir: Optional[str]) -> bool:
"""True when ``output_dir`` holds a previewable root model (what ``/p/{run}``
resolves). A cancelled run keeps ``output_dir`` but saves no root adapter."""
if not output_dir:
return False
path = Path(output_dir)
return path.is_dir() and _is_model_dir(path)
def preview_ref(output_dir: Optional[str]) -> Optional[str]:
"""``/p`` ref (``run`` or ``run/checkpoint``) relative to outputs_root, or None.
Posix-joined so a nested output dir keeps a working link instead of collapsing
to its basename. None when not previewable, outside outputs_root, or deeper than
the two path segments the ``/p`` route matches (so the UI omits a dead link).
"""
if not has_preview_model(output_dir):
return None
try:
rel = Path(output_dir).resolve().relative_to(outputs_root().resolve())
except (ValueError, OSError):
return None
parts = rel.parts
if not parts or len(parts) > 2:
return None
return "/".join(parts)
def resolve_preview_checkpoint(run: str, checkpoint: Optional[str] = None) -> Path:
relative = run if not checkpoint else f"{run}/{checkpoint}"
path = resolve_output_dir(relative)
if not path.is_dir() or not _is_model_dir(path):
raise FileNotFoundError(
f"No trained checkpoint at '{relative}'. Check the run/checkpoint name (see GET /p)."
)
return path
def list_preview_targets(outputs_dir: str = str(outputs_root())) -> List[dict]:
targets: List[dict] = []
for run_name, checkpoints, metadata in scan_checkpoints(outputs_dir):
for display_name, path, loss in checkpoints:
is_latest = display_name == run_name
checkpoint = None if is_latest else Path(path).name
targets.append(
{
"run": run_name,
"checkpoint": checkpoint,
"ref": run_name if is_latest else f"{run_name}/{checkpoint}",
"is_latest": is_latest,
"loss": loss,
"base_model": metadata.get("base_model"),
}
)
return targets

View file

@ -24,7 +24,10 @@ import {
useTrainingRuntimeStore,
} from "@/features/training";
import { formatDuration } from "@/features/studio/sections/progress-section-lib";
import { fetchDeviceType, usePlatformStore } from "@/config/env";
import { cn } from "@/lib/utils";
import { copyToClipboard } from "@/lib/copy-to-clipboard";
import { toast } from "@/lib/toast";
import { Delete02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { type ReactElement, useCallback, useEffect, useRef, useState } from "react";
@ -194,6 +197,28 @@ export function HistoryCardGrid({
const [manualFetchInFlight, setManualFetchInFlight] = useState(false);
const { resumeTrainingRunFromHistory } = useTrainingActions();
const isStarting = useTrainingRuntimeStore((state) => state.isStarting);
// Copy-link base: Cloudflare tunnel > LAN host:port > origin. The tunnel
// registers shortly after startup, so poll (bounded) until it shows.
const cloudflareUrl = usePlatformStore((s) => s.cloudflareUrl);
const serverUrl = usePlatformStore((s) => s.serverUrl);
useEffect(() => {
if (cloudflareUrl) return;
let cancelled = false;
void (async () => {
for (let attempt = 0; attempt < 12 && !cancelled; attempt++) {
try {
await fetchDeviceType({ force: true });
} catch {
// Ignore startup blips; copy-link falls back to serverUrl/origin.
}
if (cancelled || usePlatformStore.getState().cloudflareUrl) return;
await new Promise((r) => setTimeout(r, 2500));
}
})();
return () => {
cancelled = true;
};
}, [cloudflareUrl]);
const userControllerRef = useRef<AbortController | null>(null);
const pollControllerRef = useRef<AbortController | null>(null);
@ -362,6 +387,8 @@ export function HistoryCardGrid({
const isRunning = run.status === "running";
const canResume = run.can_resume && !wasContinued;
const isResuming = resumeTarget === run.id;
// Backend /p ref, gated on previewability + route-expressible depth.
const canCopyPreview = !!run.preview_ref;
return (
<div
role="button"
@ -372,7 +399,7 @@ export function HistoryCardGrid({
isRunning
? "border-blue-400/50 dark:border-blue-500/30"
: "border-border/60",
canResume && "gap-2",
(canResume || canCopyPreview) && "gap-2",
)}
onClick={() => onSelectRun(run.id)}
onKeyDown={(e) => {
@ -411,6 +438,38 @@ export function HistoryCardGrid({
{isResuming ? t("studio.history.resuming") : t("studio.history.resumeTraining")}
</Button>
)}
{canCopyPreview && (
<Button
type="button"
size="xs"
variant="outline"
className="absolute bottom-3 right-4 h-6 rounded-full px-2.5 text-[11px] leading-none shadow-sm"
onClick={async (e) => {
e.stopPropagation();
// Encode each segment but keep "/" so the /p route matches.
const ref = (run.preview_ref ?? "")
.split("/")
.map(encodeURIComponent)
.join("/");
const base = (
cloudflareUrl ??
serverUrl ??
window.location.origin
).replace(/\/+$/, "");
const url = `${base}/p/${ref}`;
const ok = await copyToClipboard(url);
toast[ok ? "success" : "error"](
t(
ok
? "studio.history.previewLinkCopied"
: "studio.history.previewLinkCopyFailed",
),
);
}}
>
{t("studio.history.copyPreviewLink")}
</Button>
)}
<div className="min-w-0">
<p
className="truncate text-sm font-medium"
@ -434,7 +493,7 @@ export function HistoryCardGrid({
</p>
</div>
{run.loss_sparkline && run.loss_sparkline.length >= 2 && (
<div className={cn(canResume && "h-7 overflow-hidden")}>
<div className={cn((canResume || canCopyPreview) && "h-7 overflow-hidden")}>
<Sparkline
values={run.loss_sparkline}
id={run.id}

View file

@ -15,6 +15,8 @@ export interface TrainingRunSummary {
output_dir: string | null;
can_resume: boolean;
resumed_later: boolean;
has_preview_model: boolean;
preview_ref: string | null;
duration_seconds: number | null;
error_message: string | null;
loss_sparkline: number[] | null;

View file

@ -763,6 +763,9 @@ export const en = {
running: "Training in progress",
errored: "Training errored",
},
copyPreviewLink: "Copy preview link",
previewLinkCopied: "Preview link copied",
previewLinkCopyFailed: "Couldn't copy the link",
},
charts: {
settings: "Chart Settings",