# SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 """Datasets API routes.""" import base64 import io import json import sys from contextlib import suppress from pathlib import Path from uuid import uuid4 from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile import re as _re import structlog from loggers import get_logger _VALID_REPO_ID = _re.compile(r"^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$") def _is_valid_repo_id(repo_id: str) -> bool: return bool(_VALID_REPO_ID.fullmatch(repo_id)) _dataset_size_cache: dict[str, int] = {} def _get_dataset_size_cached(repo_id: str) -> int: if repo_id in _dataset_size_cache: return _dataset_size_cache[repo_id] try: from huggingface_hub import dataset_info as hf_dataset_info info = hf_dataset_info(repo_id, token = None, files_metadata = True) total = sum(s.size for s in info.siblings if getattr(s, "size", None)) _dataset_size_cache[repo_id] = total return total except Exception: return 0 def _resolve_hf_cache_realpath(repo_dir: Path) -> Optional[str]: """Resolved realpath for a HF cache repo dir: most-recent snapshot, else cache root. Mirrors routes/models.py; duplicated here to keep this module self-contained. """ try: snapshots_dir = repo_dir / "snapshots" if snapshots_dir.is_dir(): snaps = [s for s in snapshots_dir.iterdir() if s.is_dir()] if snaps: latest = max(snaps, key = lambda s: s.stat().st_mtime) return str(latest.resolve()) return str(repo_dir.resolve()) except Exception: return None backend_path = Path(__file__).parent.parent.parent if str(backend_path) not in sys.path: sys.path.insert(0, str(backend_path)) from utils.datasets import check_dataset_format from utils.upload_limits import get_upload_limit_bytes, get_upload_limit_label from auth.authentication import get_current_subject router = APIRouter() logger = get_logger(__name__) from models.datasets import ( AiAssistMappingRequest, AiAssistMappingResponse, CheckFormatRequest, CheckFormatResponse, LocalDatasetItem, LocalDatasetsResponse, UploadDatasetResponse, ) from utils.paths import ( dataset_uploads_root, ensure_dir, recipe_datasets_root, resolve_dataset_path, ) def _serialize_preview_value(value): """Make a value JSON-safe for the client preview.""" if value is None or isinstance(value, (str, int, float, bool)): return value try: from PIL.Image import Image as PILImage if isinstance(value, PILImage): buffer = io.BytesIO() value.convert("RGB").save(buffer, format = "JPEG", quality = 85) return { "type": "image", "mime": "image/jpeg", "width": value.width, "height": value.height, "data": base64.b64encode(buffer.getvalue()).decode("ascii"), } except Exception: pass if isinstance(value, dict): return {str(key): _serialize_preview_value(item) for key, item in value.items()} if isinstance(value, (list, tuple)): return [_serialize_preview_value(item) for item in value] return str(value) def _serialize_preview_rows(rows): return [ {str(key): _serialize_preview_value(value) for key, value in dict(row).items()} for row in rows ] # Data-file extensions for single-file preview. Tier 1 only uses tabular # files; archives/text/config fall through to full load_dataset. _COLUMNAR_EXTS = (".parquet", ".arrow") _RECORD_EXTS = (".jsonl", ".csv", ".tsv") _JSON_EXTS = (".json",) _TABULAR_EXTS = _COLUMNAR_EXTS + _RECORD_EXTS + _JSON_EXTS LOCAL_FILE_EXTS = (".json", ".jsonl", ".csv", ".parquet") LOCAL_UPLOAD_EXTS = {".csv", ".json", ".jsonl", ".parquet"} # sync: training dataset upload limits are exposed by /api/settings/upload-limit LOCAL_DATASETS_ROOT = recipe_datasets_root() DATASET_UPLOAD_DIR = dataset_uploads_root() def _safe_read_metadata(path: Path) -> dict | None: try: payload = json.loads(path.read_text(encoding = "utf-8")) except (OSError, ValueError, TypeError): return None if not isinstance(payload, dict): return None return payload _HF_PREVIEW_EXT_PRIORITY = { ".parquet": 0, ".arrow": 0, ".jsonl": 1, ".csv": 2, ".tsv": 3, ".json": 4, } _HF_NON_DATA_EXACT_FILENAMES = { ".gitattributes", "builder_config.json", "config.json", "dataset_info.json", "dataset_infos.json", "metadata.json", } _HF_NON_DATA_CARD_FILENAMES = {"card.json", "dataset_card.json"} def _normalize_hf_repo_path(path: str) -> str: return path.strip().replace("\\", "/").lstrip("./") def _hf_preview_extension(path: str) -> str | None: lower = path.lower() for ext in _HF_PREVIEW_EXT_PRIORITY: if lower.endswith(ext): return ext return None def _is_known_hf_non_data_file(path: str) -> bool: name = Path(path).name.lower() if name in _HF_NON_DATA_EXACT_FILENAMES: return True if name in _HF_NON_DATA_CARD_FILENAMES: return True if name == "readme" or name.startswith("readme."): return True if name.endswith("_config.json") or name.endswith("-config.json"): return True if name.endswith("_card.json") or name.endswith("-card.json"): return True return False def _is_hf_preview_data_file(path: str) -> bool: normalized = _normalize_hf_repo_path(path) if not normalized or _is_known_hf_non_data_file(normalized): return False return _hf_preview_extension(normalized) is not None def _extract_hf_metadata_data_paths(metadata: dict | None) -> list[str]: if not metadata: return [] file_paths = metadata.get("file_paths") if not isinstance(file_paths, dict): return [] raw_data_paths = file_paths.get("data") if isinstance(raw_data_paths, str): values = [raw_data_paths] elif isinstance(raw_data_paths, list): values = raw_data_paths else: return [] paths: list[str] = [] for value in values: if not isinstance(value, str): continue normalized = _normalize_hf_repo_path(value) if normalized: paths.append(normalized) return paths def _select_best_hf_preview_candidate( candidates: list[str], *, subset: str | None, split: str | None ) -> str | None: if not candidates: return None subset_lower = subset.lower() if subset else None split_lower = split.lower() if split else None def score(path: str) -> tuple[int, int, int, int, str]: ext = _hf_preview_extension(path) ext_priority = _HF_PREVIEW_EXT_PRIORITY[ext] if ext else 99 stem = Path(path).stem.lower() path_lower = path.lower() subset_miss = 0 if subset_lower: subset_miss = 0 if subset_lower in stem or subset_lower in path_lower else 1 split_miss = 0 if split_lower: split_hit = ( stem == split_lower or stem.startswith(f"{split_lower}_") or stem.startswith(f"{split_lower}-") or f"/{split_lower}/" in path_lower or f"_{split_lower}." in path_lower or f"-{split_lower}." in path_lower or f"/{split_lower}." in path_lower or f"/{split_lower}_" in path_lower or f"/{split_lower}-" in path_lower ) split_miss = 0 if split_hit else 1 return (subset_miss, split_miss, ext_priority, len(path), path) return sorted(candidates, key = score)[0] def _select_hf_preview_file( repo_files: list[str], *, metadata: dict | None, subset: str | None, split: str | None ) -> str | None: normalized_repo_files = [_normalize_hf_repo_path(path) for path in repo_files] repo_file_set = set(normalized_repo_files) metadata_candidates = [ path for path in _extract_hf_metadata_data_paths(metadata) if path in repo_file_set and _is_hf_preview_data_file(path) ] if metadata_candidates: return _select_best_hf_preview_candidate(metadata_candidates, subset = subset, split = split) data_candidates = [path for path in normalized_repo_files if _is_hf_preview_data_file(path)] return _select_best_hf_preview_candidate(data_candidates, subset = subset, split = split) def _download_hf_metadata(*, repo_id: str, repo_files: list[str], token: str | None) -> dict | None: metadata_file = next( ( path for path in repo_files if Path(_normalize_hf_repo_path(path)).name.lower() == "metadata.json" ), None, ) if not metadata_file: return None try: from huggingface_hub import hf_hub_download local_path = hf_hub_download( repo_id = repo_id, filename = metadata_file, repo_type = "dataset", token = token, ) except Exception as exc: logger.warning(f"Could not read HF dataset metadata for {repo_id}: {exc}") return None return _safe_read_metadata(Path(local_path)) def _safe_read_rows_from_metadata(payload: dict | None) -> int | None: if not payload: return None for key in ("actual_num_records", "target_num_records"): value = payload.get(key) if isinstance(value, int): return value return None def _safe_read_metadata_summary(payload: dict | None) -> dict | None: if not payload: return None actual_num_records = ( payload.get("actual_num_records") if isinstance(payload.get("actual_num_records"), int) else None ) target_num_records = ( payload.get("target_num_records") if isinstance(payload.get("target_num_records"), int) else actual_num_records ) columns: list[str] | None = None schema = payload.get("schema") if isinstance(schema, dict): columns = [str(key) for key in schema.keys()] if not columns: stats = payload.get("column_statistics") if isinstance(stats, list): derived = [ str(item.get("column_name")) for item in stats if isinstance(item, dict) and item.get("column_name") ] columns = derived or None parquet_files_count = None file_paths = payload.get("file_paths") if isinstance(file_paths, dict): parquet_files = file_paths.get("parquet-files") if isinstance(parquet_files, list): parquet_files_count = len(parquet_files) total_num_batches = ( payload.get("total_num_batches") if isinstance(payload.get("total_num_batches"), int) else parquet_files_count ) num_completed_batches = ( payload.get("num_completed_batches") if isinstance(payload.get("num_completed_batches"), int) else total_num_batches ) return { "actual_num_records": actual_num_records, "target_num_records": target_num_records, "total_num_batches": total_num_batches, "num_completed_batches": num_completed_batches, "columns": columns, } def _build_local_dataset_items() -> list[LocalDatasetItem]: if not LOCAL_DATASETS_ROOT.exists(): return [] items: list[LocalDatasetItem] = [] for entry in LOCAL_DATASETS_ROOT.iterdir(): if not entry.is_dir() or not entry.name.startswith("recipe_"): continue parquet_dir = entry / "parquet-files" if not parquet_dir.exists() or not any(parquet_dir.glob("*.parquet")): continue rows = None metadata_summary = None metadata_path = entry / "metadata.json" if metadata_path.exists(): metadata_payload = _safe_read_metadata(metadata_path) rows = _safe_read_rows_from_metadata(metadata_payload) metadata_summary = _safe_read_metadata_summary(metadata_payload) try: updated_at = entry.stat().st_mtime except OSError: updated_at = None items.append( LocalDatasetItem( id = entry.name, label = entry.name, path = str(parquet_dir.resolve()), rows = rows, updated_at = updated_at, metadata = metadata_summary, ) ) items.sort(key = lambda item: item.updated_at or 0, reverse = True) return items def _load_local_preview_slice(*, dataset_path: Path, train_split: str, preview_size: int): # Non-streaming loads take the cached builder lock; use the EACCES-safe wrapper. from utils.datasets.cache_safe import load_dataset_cache_safe as load_dataset if dataset_path.is_dir(): parquet_dir = ( dataset_path / "parquet-files" if (dataset_path / "parquet-files").exists() else dataset_path ) parquet_files = sorted(parquet_dir.glob("*.parquet")) if parquet_files: dataset = load_dataset( "parquet", data_files = [str(path) for path in parquet_files], split = train_split, ) total_rows = len(dataset) preview_slice = dataset.select(range(min(preview_size, total_rows))) return preview_slice, total_rows else: candidate_files: list[Path] = [] for ext in LOCAL_FILE_EXTS: candidate_files.extend(sorted(dataset_path.glob(f"*{ext}"))) if not candidate_files: raise HTTPException( status_code = 400, detail = "Unsupported local dataset directory (expected parquet/json/jsonl/csv files)", ) dataset_path = candidate_files[0] if dataset_path.suffix in [".json", ".jsonl"]: dataset = load_dataset("json", data_files = str(dataset_path), split = train_split) elif dataset_path.suffix == ".csv": dataset = load_dataset("csv", data_files = str(dataset_path), split = train_split) elif dataset_path.suffix == ".parquet": dataset = load_dataset("parquet", data_files = str(dataset_path), split = train_split) else: raise HTTPException( status_code = 400, detail = f"Unsupported file format: {dataset_path.suffix}" ) total_rows = len(dataset) preview_slice = dataset.select(range(min(preview_size, total_rows))) return preview_slice, total_rows def _sanitize_filename(filename: str) -> str: name = Path(filename).name.strip().replace("\x00", "") if not name: return "dataset_upload" return name @router.post("/upload", response_model = UploadDatasetResponse) async def upload_dataset( file: UploadFile, current_subject: str = Depends(get_current_subject) ) -> UploadDatasetResponse: filename = _sanitize_filename(file.filename or "dataset_upload") ext = Path(filename).suffix.lower() if ext not in LOCAL_UPLOAD_EXTS: allowed = ", ".join(sorted(LOCAL_UPLOAD_EXTS)) raise HTTPException( status_code = 400, detail = f"Unsupported file type: {ext}. Allowed: {allowed}", ) ensure_dir(DATASET_UPLOAD_DIR) stem = Path(filename).stem stored_name = f"{uuid4().hex}_{stem}{ext}" stored_path = DATASET_UPLOAD_DIR / stored_name # Stream to disk in chunks to avoid holding the whole file in memory. The # route-level cap gives a clear training-dataset error and avoids leaving # oversized partial files in the Studio uploads directory. upload_limit_bytes = get_upload_limit_bytes() total_bytes = 0 upload_complete = False try: with open(stored_path, "wb") as f: while chunk := await file.read(1024 * 1024): total_bytes += len(chunk) if total_bytes > upload_limit_bytes: raise HTTPException( status_code = 413, detail = ( "Training dataset upload too large. " f"Maximum is {get_upload_limit_label()}." ), ) f.write(chunk) upload_complete = True finally: if not upload_complete: with suppress(OSError): stored_path.unlink(missing_ok = True) if stored_path.stat().st_size == 0: stored_path.unlink(missing_ok = True) raise HTTPException(status_code = 400, detail = "Empty upload payload") return UploadDatasetResponse(filename = filename, stored_path = str(stored_path)) @router.get("/local", response_model = LocalDatasetsResponse) def list_local_datasets( current_subject: str = Depends(get_current_subject), ) -> LocalDatasetsResponse: return LocalDatasetsResponse(datasets = _build_local_dataset_items()) @router.get("/download-progress") async def get_dataset_download_progress( repo_id: str = Query(..., description = "HuggingFace dataset repo ID, e.g. 'unsloth/LaTeX_OCR'"), current_subject: str = Depends(get_current_subject), ): """Return download progress for a HuggingFace dataset repo. Mirrors ``GET /api/models/download-progress`` but scans the ``datasets--owner--name`` cache dir under HF_HUB_CACHE, where in-progress download bytes are visible. Returns ``cache_path`` so the UI can show it. """ _empty = { "downloaded_bytes": 0, "expected_bytes": 0, "progress": 0, "cache_path": None, } try: if not _is_valid_repo_id(repo_id): return _empty from huggingface_hub import constants as hf_constants cache_dir = Path(hf_constants.HF_HUB_CACHE) target = f"datasets--{repo_id.replace('/', '--')}".lower() completed_bytes = 0 in_progress_bytes = 0 cache_path: Optional[str] = None if cache_dir.is_dir(): for entry in cache_dir.iterdir(): if entry.name.lower() != target: continue cache_path = _resolve_hf_cache_realpath(entry) blobs_dir = entry / "blobs" if not blobs_dir.is_dir(): break for f in blobs_dir.iterdir(): if not f.is_file(): continue if f.name.endswith(".incomplete"): in_progress_bytes += f.stat().st_size else: completed_bytes += f.stat().st_size break downloaded_bytes = completed_bytes + in_progress_bytes if downloaded_bytes == 0: return {**_empty, "cache_path": cache_path} expected_bytes = _get_dataset_size_cached(repo_id) if expected_bytes <= 0: return { "downloaded_bytes": downloaded_bytes, "expected_bytes": 0, "progress": 0, "cache_path": cache_path, } # 95% threshold (as in the model endpoint): HF blob dedup makes # completed_bytes drift under expected_bytes; inter-file gaps look "done". if completed_bytes >= expected_bytes * 0.95: progress = 1.0 else: progress = min(downloaded_bytes / expected_bytes, 0.99) return { "downloaded_bytes": downloaded_bytes, "expected_bytes": expected_bytes, "progress": round(progress, 3), "cache_path": cache_path, } except Exception as e: logger.warning(f"Error checking dataset download progress for {repo_id}: {e}") return _empty @router.post("/check-format", response_model = CheckFormatResponse) def check_format(request: CheckFormatRequest, current_subject: str = Depends(get_current_subject)): """Check if a dataset requires manual column mapping. HuggingFace strategy: 1. list_repo_files -> select one tabular data file -> load_dataset (avoids resolving thousands of files; ~2-4 s). 2. Full streaming load_dataset as a last-resort fallback. Local files load directly. Plain `def` (not async) so FastAPI runs it in a thread-pool, keeping blocking IO off the event loop. """ try: from itertools import islice from datasets import Dataset, load_dataset from utils.datasets import format_dataset PREVIEW_SIZE = 10 logger.info(f"Checking format for dataset: {request.dataset_name}") dataset_path = resolve_dataset_path(request.dataset_name) total_rows = None if dataset_path.exists(): # ── Local file ────────────────────────────────────────── train_split = request.train_split or "train" preview_slice, total_rows = _load_local_preview_slice( dataset_path = dataset_path, train_split = train_split, preview_size = PREVIEW_SIZE, ) else: # ── HuggingFace dataset ───────────────────────────────── # Tier 1: list_repo_files -> load one selected tabular data file preview_slice = None try: from huggingface_hub import HfApi api = HfApi() repo_files = api.list_repo_files( request.dataset_name, repo_type = "dataset", token = request.hf_token or None, ) metadata = _download_hf_metadata( repo_id = request.dataset_name, repo_files = repo_files, token = request.hf_token or None, ) selected_file = _select_hf_preview_file( repo_files, metadata = metadata, subset = request.subset, split = request.train_split or "train", ) if selected_file: logger.info(f"Tier 1: loading single file {selected_file}") load_kwargs = { "path": request.dataset_name, "data_files": [selected_file], "split": "train", "streaming": True, } if request.hf_token: load_kwargs["token"] = request.hf_token streamed_ds = load_dataset(**load_kwargs) rows = list(islice(streamed_ds, PREVIEW_SIZE)) if rows: preview_slice = Dataset.from_list(rows) except Exception as e: logger.warning(f"Tier 1 (single-file) failed: {e}") if preview_slice is None: # Tier 2: full streaming (resolves all files; slow for large repos) logger.info("Tier 2: falling back to full streaming load_dataset") load_kwargs = { "path": request.dataset_name, "split": request.train_split, "streaming": True, } if request.subset: load_kwargs["name"] = request.subset if request.hf_token: load_kwargs["token"] = request.hf_token streamed_ds = load_dataset(**load_kwargs) rows = list(islice(streamed_ds, PREVIEW_SIZE)) if not rows: raise HTTPException( status_code = 400, detail = "Dataset appears to be empty or could not be streamed", ) preview_slice = Dataset.from_list(rows) total_rows = None result = check_dataset_format(preview_slice, is_vlm = request.is_vlm) logger.info( f"Format check result: requires_mapping={result['requires_manual_mapping']}, format={result['detected_format']}, is_image={result.get('is_image', False)}" ) preview_samples = None if not result["requires_manual_mapping"]: if result.get("suggested_mapping"): # Heuristic-detected: show raw data so columns match the API response. # Column stripping happens at training time, not preview. preview_samples = _serialize_preview_rows(preview_slice) else: try: format_result = format_dataset( preview_slice, format_type = "auto", num_proc = None, # Only 10 preview rows ) processed = format_result["dataset"] preview_samples = _serialize_preview_rows(processed) except Exception as e: logger.warning(f"Processed preview generation failed (non-fatal): {e}") preview_samples = _serialize_preview_rows(preview_slice) else: preview_samples = _serialize_preview_rows(preview_slice) # Warnings from check_dataset_format plus URL-based image detection. warning = result.get("warning") image_col = result.get("detected_image_column") if image_col and image_col in (result.get("columns") or []): try: sample_val = preview_slice[0][image_col] if isinstance(sample_val, str) and sample_val.startswith(("http://", "https://")): url_warning = ( "This dataset contains image URLs instead of embedded images. " "Images will be downloaded during training, which may be slow for large datasets." ) logger.info(f"URL-based image column detected: {image_col}") warning = f"{warning} {url_warning}" if warning else url_warning except Exception: pass return CheckFormatResponse( requires_manual_mapping = result["requires_manual_mapping"], detected_format = result["detected_format"], columns = result["columns"], is_image = result.get("is_image", False), is_audio = result.get("is_audio", False), multimodal_columns = result.get("multimodal_columns"), suggested_mapping = result.get("suggested_mapping"), detected_image_column = result.get("detected_image_column"), detected_audio_column = result.get("detected_audio_column"), detected_text_column = result.get("detected_text_column"), detected_speaker_column = result.get("detected_speaker_column"), chat_column = result.get("chat_column"), preview_samples = preview_samples, total_rows = total_rows, warning = warning, ) except HTTPException: raise except Exception as e: logger.error(f"Error checking dataset format: {e}", exc_info = True) raise HTTPException(status_code = 500, detail = "Failed to check dataset format") @router.post("/ai-assist-mapping", response_model = AiAssistMappingResponse) def ai_assist_mapping( request: AiAssistMappingRequest, current_subject: str = Depends(get_current_subject) ): """Run LLM-assisted dataset conversion advisor (user-triggered). Multi-pass analysis with a 7B helper model: classify dataset type, generate conversion strategy, validate quality. Falls back to simple column classification if the advisor fails. """ try: from utils.datasets.llm_assist import llm_conversion_advisor # Truncate sample values for the LLM prompt. truncated = [ {col: str(s.get(col, ""))[:200] for col in request.columns} for s in request.samples[:5] ] result = llm_conversion_advisor( column_names = request.columns, samples = truncated, dataset_name = request.dataset_name, hf_token = request.hf_token, model_name = request.model_name, model_type = request.model_type, ) if result and result.get("success"): return AiAssistMappingResponse( success = True, suggested_mapping = result.get("suggested_mapping"), system_prompt = result.get("system_prompt"), user_template = result.get("user_template"), assistant_template = result.get("assistant_template"), label_mapping = result.get("label_mapping"), dataset_type = result.get("dataset_type"), is_conversational = result.get("is_conversational"), user_notification = result.get("user_notification"), ) return AiAssistMappingResponse( success = False, warning = "AI could not determine column roles. Please assign them manually.", ) except Exception as e: logger.error(f"AI assist mapping failed: {e}", exc_info = True) raise HTTPException(status_code = 500, detail = "AI assist failed")