unsloth/studio/backend/routes/training_history.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

133 lines
4.5 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
"""
Training history API routes — browse, view, and delete past training runs.
"""
import json
from fastapi import APIRouter, Depends, HTTPException, Query
from loggers import get_logger
from auth.authentication import get_current_subject
from core.training.resume import can_resume_run
from models import (
TrainingRunDeleteResponse,
TrainingRunDetailResponse,
TrainingRunListResponse,
TrainingRunMetrics,
TrainingRunSummary,
TrainingRunUpdateRequest,
)
from storage.studio_db import (
delete_run,
get_run,
get_run_metrics,
list_runs,
update_run_display_name,
)
from utils.models.checkpoints import has_preview_model, preview_ref
logger = get_logger(__name__)
router = APIRouter()
@router.get("/runs", response_model = TrainingRunListResponse)
async def list_training_runs(
limit: int = Query(50, ge = 1, le = 200),
offset: int = Query(0, ge = 0),
current_subject: str = Depends(get_current_subject),
):
"""List training runs, newest first."""
result = list_runs(limit = limit, offset = offset)
return TrainingRunListResponse(
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"],
)
@router.get("/runs/{run_id}", response_model = TrainingRunDetailResponse)
async def get_training_run_detail(run_id: str, current_subject: str = Depends(get_current_subject)):
"""Get a single training run with full config and metrics."""
run = get_run(run_id)
if run is None:
raise HTTPException(status_code = 404, detail = f"Run {run_id} not found")
try:
config = json.loads(run.get("config_json", "{}"))
except (json.JSONDecodeError, TypeError):
logger.debug("Failed to parse config_json for run %s", run_id)
config = {}
metrics_data = get_run_metrics(run_id)
return TrainingRunDetailResponse(
run = TrainingRunSummary(
**{
**{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,
metrics = TrainingRunMetrics(**metrics_data),
)
@router.patch("/runs/{run_id}", response_model = TrainingRunSummary)
async def update_training_run(
run_id: str,
payload: TrainingRunUpdateRequest,
current_subject: str = Depends(get_current_subject),
):
"""Update mutable fields on a training run (currently only display_name)."""
run = get_run(run_id)
if run is None:
raise HTTPException(status_code = 404, detail = f"Run {run_id} not found")
if "display_name" in payload.model_fields_set:
next_display = payload.display_name
if next_display is not None:
next_display = next_display.strip() or None
update_run_display_name(run_id, next_display)
refreshed = get_run(run_id)
if refreshed is None:
raise HTTPException(status_code = 404, detail = f"Run {run_id} not found")
return TrainingRunSummary(
**{
**{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")),
}
)
@router.delete("/runs/{run_id}", response_model = TrainingRunDeleteResponse)
async def delete_training_run(run_id: str, current_subject: str = Depends(get_current_subject)):
"""Delete a training run and its metrics (CASCADE)."""
run = get_run(run_id)
if run is None:
raise HTTPException(status_code = 404, detail = f"Run {run_id} not found")
if run["status"] == "running":
raise HTTPException(status_code = 409, detail = "Cannot delete a running training run")
logger.info("Deleting training run %s", run_id)
delete_run(run_id)
return TrainingRunDeleteResponse(
status = "deleted",
message = f"Run {run_id} deleted",
)