# 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, ) 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)}) 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), } ), 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), } ) @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", )