(feat) Add project names to studio training runs (#6512)

* (feat) Add project names to studio training runs to avoid models being overwritten when doing similar training runs

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

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

* Update studio/frontend/src/features/export/export-page.tsx

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update studio/frontend/src/features/export/export-page.tsx

Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>

* Update studio/frontend/src/features/export/export-page.tsx

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* better project name sanitization, removed duplicated project name normalization

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

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

* implement checkpoint scanning utilities and tests for base model inference

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

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

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

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

* Guard project_name against null and use leading important modifiers

* Fix/adjust training project names for PR #6512

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

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

* Fix/adjust training project names for PR #6512

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

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

* Address project-name review feedback

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

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

* Show project names in training recents

* Keep GGUF export directories source-specific

---------

Co-authored-by: NZ-Linix <nz-linix@outlook.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
Co-authored-by: NZ-Linix <linus.ordowski@outlook.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: wasimysaid <wasimysdev@gmail.com>
This commit is contained in:
Michael Han 2026-06-29 07:06:36 -07:00 committed by GitHub
commit 11469a60fe
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 804 additions and 29 deletions

View file

@ -299,6 +299,7 @@ class TrainingBackend:
# Build config dict for the subprocess
config = {
"model_name": kwargs["model_name"],
"project_name": kwargs.get("project_name"),
"training_type": kwargs.get("training_type", "LoRA/QLoRA"),
"hf_token": kwargs.get("hf_token", ""),
"load_in_4bit": kwargs.get("load_in_4bit", True),

View file

@ -44,6 +44,7 @@ if sys.platform.startswith("linux") and "HSA_ENABLE_DXG_DETECTION" not in os.env
logger = get_logger(__name__)
from utils.hardware import apply_gpu_ids
from utils.training_runs import build_default_output_dir_name
from utils.wheel_utils import (
direct_wheel_url,
flash_attn_wheel_url,
@ -1787,11 +1788,14 @@ def _run_mlx_training(event_queue, stop_queue, config):
# ── 5. Build output dir ──
# Resolve to ~/.unsloth/studio/outputs/ so the export page finds it
from utils.paths import resolve_output_dir, ensure_dir, default_run_dir_name
from utils.paths import resolve_output_dir, ensure_dir
output_dir = config.get("output_dir", "")
if not output_dir:
output_dir = f"{default_run_dir_name(model_name)}_{int(time.time())}"
output_dir = build_default_output_dir_name(
model_name,
config.get("project_name"),
)
output_dir = str(resolve_output_dir(output_dir))
ensure_dir(Path(output_dir))
@ -3019,7 +3023,10 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) ->
resume_from_checkpoint
)
if not output_dir:
output_dir = f"{default_run_dir_name(model_name)}_{int(time.time())}"
output_dir = build_default_output_dir_name(
model_name,
config.get("project_name"),
)
output_dir = str(resolve_output_dir(output_dir))
ensure_dir(Path(output_dir))
@ -3500,7 +3507,10 @@ def _run_embedding_training(event_queue: Any, stop_queue: Any, config: dict) ->
resume_from_checkpoint
)
if not output_dir:
output_dir = f"{default_run_dir_name(model_name)}_{int(time.time())}"
output_dir = build_default_output_dir_name(
model_name,
config.get("project_name"),
)
output_dir = str(resolve_output_dir(output_dir))
num_epochs = config.get("num_epochs", 2)

View file

@ -9,6 +9,8 @@ import re
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from typing import Any, Optional, List, Dict, Literal
from utils.training_runs import normalize_project_name
# ASCII integer, optional single sign. Rejects "++512" and Unicode digits
# ("") that slip through str.isdigit() + int().
@ -97,6 +99,11 @@ class TrainingStartRequest(BaseModel):
model_name: str = Field(
..., description = "Model identifier (e.g., 'unsloth/llama-3-8b-bnb-4bit')"
)
project_name: Optional[str] = Field(
None,
max_length = 80,
description = "Optional user-defined project name appended to run folders and shown in history",
)
training_type: Literal["LoRA/QLoRA", "Full Finetuning", "Continued Pretraining"] = Field(
...,
description = "Training type: 'LoRA/QLoRA', 'Full Finetuning', or 'Continued Pretraining'",
@ -155,6 +162,11 @@ class TrainingStartRequest(BaseModel):
values.setdefault("train_split", values.pop("split"))
return values
@field_validator("project_name")
@classmethod
def _normalize_project_name(cls, value: Optional[str]) -> Optional[str]:
return normalize_project_name(value)
# NOTE: pydantic runs all `mode="after"` validators in definition order. A
# second one, `_check_steps_or_epochs`, is defined lower in this class; keep
# these cross-field checks order-independent so the two stay decoupled.
@ -588,6 +600,7 @@ class TrainingRunSummary(BaseModel):
id: str
status: Literal["running", "completed", "stopped", "error"]
model_name: str
project_name: Optional[str] = None
dataset_name: str
display_name: Optional[str] = None
started_at: str

View file

@ -255,6 +255,7 @@ async def start_training(
# Convert request to backend kwargs.
training_kwargs = {
"model_name": request.model_name,
"project_name": request.project_name,
"training_type": request.training_type,
"hf_token": request.hf_token or "",
"load_in_4bit": request.load_in_4bit,

View file

@ -23,6 +23,16 @@ from typing import Any, Iterable, Optional
from utils.paths import project_workspaces_root, studio_db_path, ensure_dir
from utils.training_runs import extract_project_name
def _extract_project_name_from_config_json(config_json: Optional[str]) -> Optional[str]:
if not config_json:
return None
try:
return extract_project_name(json.loads(config_json))
except (json.JSONDecodeError, TypeError):
return None
def _denied_path_prefixes() -> list[str]:
@ -680,6 +690,7 @@ def list_runs(limit: int = 50, offset: int = 0) -> dict:
runs = []
for row in rows:
run = dict(row)
run["project_name"] = _extract_project_name_from_config_json(run.get("config_json"))
sparkline = run.get("loss_sparkline")
if sparkline:
try:
@ -719,6 +730,7 @@ def get_run(id: str) -> Optional[dict]:
if row is None:
return None
run = dict(row)
run["project_name"] = _extract_project_name_from_config_json(run.get("config_json"))
sparkline = run.get("loss_sparkline")
if sparkline:
try:

View file

@ -0,0 +1,256 @@
# 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
import sqlite3
import sys
import types as _types
from pathlib import Path
_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)
sys.modules.setdefault("structlog", _types.ModuleType("structlog"))
from utils.models import checkpoints as checkpoints_module
from utils.training_runs import build_default_output_dir_name
def _make_history_connection(db_path: Path) -> sqlite3.Connection:
conn = sqlite3.connect(str(db_path))
conn.row_factory = sqlite3.Row
return conn
def _setup_training_runs_table(db_path: Path) -> None:
conn = _make_history_connection(db_path)
try:
conn.execute(
"""
CREATE TABLE training_runs (
id TEXT PRIMARY KEY,
model_name TEXT NOT NULL,
config_json TEXT NOT NULL,
output_dir TEXT,
started_at TEXT NOT NULL
)
"""
)
conn.commit()
finally:
conn.close()
def _make_outputs_dir(tmp_path, monkeypatch) -> Path:
studio_home = tmp_path / "studio-home"
outputs_dir = studio_home / "outputs"
outputs_dir.mkdir(parents = True)
monkeypatch.setenv("UNSLOTH_STUDIO_HOME", str(studio_home))
return outputs_dir
def test_scan_checkpoints_uses_output_dir_history_for_base_model(tmp_path, monkeypatch):
outputs_dir = _make_outputs_dir(tmp_path, monkeypatch)
run_dir = outputs_dir / "custom-run"
run_dir.mkdir()
(run_dir / "config.json").write_text("{}")
db_path = tmp_path / "studio.db"
_setup_training_runs_table(db_path)
conn = _make_history_connection(db_path)
try:
conn.execute(
"""
INSERT INTO training_runs (id, model_name, config_json, output_dir, started_at)
VALUES (?, ?, ?, ?, ?)
""",
(
"run-1",
"unsloth/Llama-3.2-3B-Instruct",
"{}",
str(run_dir.resolve()),
"2026-04-09T00:00:00Z",
),
)
conn.commit()
finally:
conn.close()
monkeypatch.setattr(
checkpoints_module,
"get_connection",
lambda: _make_history_connection(db_path),
)
models = checkpoints_module.scan_checkpoints(outputs_dir = str(outputs_dir))
assert models[0][2]["base_model"] == "unsloth/Llama-3.2-3B-Instruct"
def test_scan_checkpoints_matches_project_suffixed_default_dir_against_history(
tmp_path, monkeypatch
):
outputs_dir = _make_outputs_dir(tmp_path, monkeypatch)
run_name = build_default_output_dir_name(
"unsloth/Llama-3.2-3B-Instruct",
"Customer Support",
timestamp = 1771227800,
)
run_dir = outputs_dir / run_name
run_dir.mkdir()
(run_dir / "config.json").write_text("{}")
db_path = tmp_path / "studio.db"
_setup_training_runs_table(db_path)
conn = _make_history_connection(db_path)
try:
conn.execute(
"""
INSERT INTO training_runs (id, model_name, config_json, output_dir, started_at)
VALUES (?, ?, ?, ?, ?)
""",
(
"run-2",
"unsloth/Llama-3.2-3B-Instruct",
json.dumps({"project_name": "Customer Support"}),
None,
"2026-04-09T00:00:00Z",
),
)
conn.commit()
finally:
conn.close()
monkeypatch.setattr(
checkpoints_module,
"get_connection",
lambda: _make_history_connection(db_path),
)
models = checkpoints_module.scan_checkpoints(outputs_dir = str(outputs_dir))
assert models[0][2]["base_model"] == "unsloth/Llama-3.2-3B-Instruct"
def test_scan_checkpoints_strips_project_suffix_without_history(tmp_path, monkeypatch):
outputs_dir = _make_outputs_dir(tmp_path, monkeypatch)
run_name = build_default_output_dir_name(
"unsloth/Llama-3.2-3B-Instruct",
"Customer Support",
timestamp = 1771227800,
)
run_dir = outputs_dir / run_name
run_dir.mkdir()
(run_dir / "config.json").write_text("{}")
db_path = tmp_path / "studio.db"
_setup_training_runs_table(db_path)
monkeypatch.setattr(
checkpoints_module,
"get_connection",
lambda: _make_history_connection(db_path),
)
models = checkpoints_module.scan_checkpoints(outputs_dir = str(outputs_dir))
assert models[0][2]["base_model"] == "unsloth/Llama-3.2-3B-Instruct"
def test_scan_checkpoints_preserves_project_marker_in_model_without_history(tmp_path, monkeypatch):
outputs_dir = _make_outputs_dir(tmp_path, monkeypatch)
run_name = build_default_output_dir_name(
"org/foo__project-bar",
timestamp = 1771227800,
)
run_dir = outputs_dir / run_name
run_dir.mkdir()
(run_dir / "config.json").write_text("{}")
db_path = tmp_path / "studio.db"
_setup_training_runs_table(db_path)
monkeypatch.setattr(
checkpoints_module,
"get_connection",
lambda: _make_history_connection(db_path),
)
models = checkpoints_module.scan_checkpoints(outputs_dir = str(outputs_dir))
assert models[0][2]["base_model"] == "org/foo__project-bar"
def test_scan_checkpoints_preserves_legacy_folder_name_fallback(tmp_path, monkeypatch):
outputs_dir = _make_outputs_dir(tmp_path, monkeypatch)
run_dir = outputs_dir / "unsloth_Llama-3.2-3B-Instruct_1771227800"
run_dir.mkdir()
(run_dir / "config.json").write_text("{}")
db_path = tmp_path / "studio.db"
_setup_training_runs_table(db_path)
monkeypatch.setattr(
checkpoints_module,
"get_connection",
lambda: _make_history_connection(db_path),
)
models = checkpoints_module.scan_checkpoints(outputs_dir = str(outputs_dir))
assert models[0][2]["base_model"] == "unsloth/Llama-3.2-3B-Instruct"
def test_scan_checkpoints_prefers_exact_history_match_over_newer_suffix(tmp_path, monkeypatch):
outputs_dir = _make_outputs_dir(tmp_path, monkeypatch)
run_dir = outputs_dir / "unsloth_Test_1771227800"
run_dir.mkdir()
(run_dir / "config.json").write_text("{}")
copied_dir = tmp_path / "copied" / run_dir.name
copied_dir.mkdir(parents = True)
db_path = tmp_path / "studio.db"
_setup_training_runs_table(db_path)
conn = _make_history_connection(db_path)
try:
conn.execute(
"""
INSERT INTO training_runs (id, model_name, config_json, output_dir, started_at)
VALUES (?, ?, ?, ?, ?)
""",
(
"run-exact",
"correct/base",
"{}",
str(run_dir.resolve()),
"2026-04-09T00:00:00Z",
),
)
conn.execute(
"""
INSERT INTO training_runs (id, model_name, config_json, output_dir, started_at)
VALUES (?, ?, ?, ?, ?)
""",
(
"run-suffix",
"wrong/base",
"{}",
str(copied_dir.resolve()),
"2026-04-10T00:00:00Z",
),
)
conn.commit()
finally:
conn.close()
monkeypatch.setattr(
checkpoints_module,
"get_connection",
lambda: _make_history_connection(db_path),
)
models = checkpoints_module.scan_checkpoints(outputs_dir = str(outputs_dir))
assert models[0][2]["base_model"] == "correct/base"

View file

@ -0,0 +1,103 @@
# 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 storage.studio_db import _extract_project_name_from_config_json
from utils.training_runs import (
build_default_output_dir_name,
model_segment_from_default_output_dir_name,
normalize_project_name,
slugify_project_name,
)
def test_normalize_project_name_trims_and_collapses_whitespace():
assert normalize_project_name(" Customer Support LoRA ") == "Customer Support LoRA"
def test_normalize_project_name_returns_none_for_empty_or_invalid_values():
assert normalize_project_name(" ") is None
assert normalize_project_name(None) is None
def test_slugify_project_name_makes_safe_suffix():
assert slugify_project_name("Customer Support / LoRA v2") == "customer-support-lora-v2"
def test_slugify_project_name_rejects_path_only_or_separator_only_values():
assert slugify_project_name("..") is None
assert slugify_project_name("///") is None
def test_build_default_output_dir_name_appends_project_slug():
output_dir = build_default_output_dir_name(
"unsloth/Llama-3.2-3B-Instruct",
"Customer Support",
timestamp = 1771227800,
)
assert output_dir == "unsloth_Llama-3.2-3B-Instruct__project-customer-support_1771227800"
def test_build_default_output_dir_name_caps_final_component(tmp_path):
output_dir = build_default_output_dir_name(
"a" * 240,
"b" * 80,
timestamp = 1771227800,
)
assert len(output_dir.encode()) <= 255
(tmp_path / output_dir).mkdir()
def test_build_default_output_dir_name_skips_invalid_project_slug():
output_dir = build_default_output_dir_name(
"unsloth/Llama-3.2-3B-Instruct",
"..",
timestamp = 1771227800,
)
assert output_dir == "unsloth_Llama-3.2-3B-Instruct_1771227800"
def test_model_segment_from_default_output_dir_name_strips_project_slug():
assert (
model_segment_from_default_output_dir_name(
"unsloth_Llama-3.2-3B-Instruct__project-customer-support_1771227800"
)
== "unsloth_Llama-3.2-3B-Instruct"
)
def test_model_segment_preserves_project_marker_text_in_model_name():
output_dir = build_default_output_dir_name(
"org/foo__project-bar",
timestamp = 1771227800,
)
assert output_dir == "org_foo__project--bar_1771227800"
assert model_segment_from_default_output_dir_name(output_dir) == "org_foo__project-bar"
def test_model_segment_strips_project_slug_after_escaped_model_marker():
output_dir = build_default_output_dir_name(
"org/foo__project-bar",
"Customer Support",
timestamp = 1771227800,
)
assert output_dir == "org_foo__project--bar__project-customer-support_1771227800"
assert model_segment_from_default_output_dir_name(output_dir) == "org_foo__project-bar"
def test_extract_project_name_from_config_json_returns_normalized_name():
config_json = json.dumps({"project_name": " Sales Assistant "})
assert _extract_project_name_from_config_json(config_json) == "Sales Assistant"
def test_extract_project_name_from_config_json_handles_missing_or_invalid_payload():
assert _extract_project_name_from_config_json(None) is None
assert _extract_project_name_from_config_json("not-json") is None
assert _extract_project_name_from_config_json(json.dumps({"project_name": " "})) is None

View file

@ -195,6 +195,16 @@ def test_hf_dataset_rejects_unsafe_values(bad_hf_dataset):
)
def test_project_name_rejects_values_over_ui_limit():
with pytest.raises(ValidationError):
TrainingStartRequest(
model_name = "unsloth/test",
project_name = "x" * 81,
training_type = "LoRA/QLoRA",
format_type = "alpaca",
)
# --- Start-route streaming compatibility guards ---

View file

@ -9,6 +9,12 @@ import structlog
from loggers import get_logger
from pathlib import Path
from typing import List, Optional, Tuple
from storage.studio_db import get_connection
from utils.training_runs import (
build_default_output_dir_name,
extract_project_name,
model_segment_from_default_output_dir_name,
)
from utils.paths import outputs_root, resolve_output_dir
logger = get_logger(__name__)
@ -30,6 +36,93 @@ def _checkpoint_sort_key(checkpoint_path: Path) -> tuple[int, int, str]:
return (1, 0, str(checkpoint_path))
def _infer_base_model_from_history(checkpoint_dir: Path) -> Optional[str]:
"""Best-effort base-model lookup using persisted Studio run metadata."""
checkpoint_name = checkpoint_dir.name
resolved_checkpoint_dir = str(checkpoint_dir.resolve())
try:
conn = get_connection()
except Exception:
return None
try:
exact_rows = conn.execute(
"""
SELECT model_name
FROM training_runs
WHERE output_dir IN (?, ?)
ORDER BY started_at DESC
""",
(
resolved_checkpoint_dir,
str(checkpoint_dir),
),
).fetchall()
for row in exact_rows:
model_name = row["model_name"]
if model_name:
return model_name
suffix_rows = conn.execute(
"""
SELECT model_name, output_dir
FROM training_runs
WHERE output_dir IS NOT NULL
ORDER BY started_at DESC
"""
).fetchall()
for row in suffix_rows:
output_dir = str(row["output_dir"] or "").rstrip("/\\")
if not (
output_dir.endswith(f"/{checkpoint_name}")
or output_dir.endswith(f"\\{checkpoint_name}")
):
continue
model_name = row["model_name"]
if model_name:
return model_name
parts = checkpoint_name.rsplit("_", 1)
if len(parts) != 2 or not parts[1].isdigit():
return None
timestamp = int(parts[1])
generated_rows = conn.execute(
"""
SELECT model_name, config_json
FROM training_runs
ORDER BY started_at DESC
"""
).fetchall()
for row in generated_rows:
model_name = row["model_name"]
if not model_name:
continue
project_name = None
config_json = row["config_json"]
if config_json:
try:
project_name = extract_project_name(json.loads(config_json))
except (TypeError, json.JSONDecodeError):
project_name = None
expected_dir_name = build_default_output_dir_name(
model_name,
project_name,
timestamp = timestamp,
)
if expected_dir_name == checkpoint_name:
return model_name
except Exception:
return None
finally:
conn.close()
return None
def _read_checkpoint_loss(checkpoint_path: Path) -> Optional[float]:
"""Read loss from the last log_history entry of trainer_state.json, or None."""
trainer_state = checkpoint_path / "trainer_state.json"
@ -106,9 +199,11 @@ def scan_checkpoints(
# Fallback: extract base model name from the folder name, e.g.
# "unsloth_Llama-3.2-3B-Instruct_1771227800" → "unsloth/Llama-3.2-3B-Instruct"
if not metadata.get("base_model"):
parts = item.name.rsplit("_", 1)
if len(parts) == 2 and parts[1].isdigit():
name_part = parts[0]
metadata["base_model"] = _infer_base_model_from_history(item)
if not metadata.get("base_model"):
name_part = model_segment_from_default_output_dir_name(item.name)
if name_part:
idx = name_part.find("_")
if idx > 0:
metadata["base_model"] = name_part[:idx] + "/" + name_part[idx + 1 :]

View file

@ -0,0 +1,104 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Helpers for naming and describing Studio training runs."""
from __future__ import annotations
import re
import time
from typing import Any, Optional
_INVALID_SEGMENT_CHARS = re.compile(r"[^A-Za-z0-9._-]+")
_MAX_RUN_DIR_NAME_CHARS = 255
_PROJECT_MARKER = "__project-"
_PROJECT_MARKER_ESCAPE = f"{_PROJECT_MARKER}-"
def _trim_segment(segment: str, max_chars: int) -> str:
if max_chars <= 0:
return ""
return segment[:max_chars].strip("._-")
def _escape_project_marker(segment: str) -> str:
return segment.replace(_PROJECT_MARKER, _PROJECT_MARKER_ESCAPE)
def _unescape_project_marker(segment: str) -> str:
return segment.replace(_PROJECT_MARKER_ESCAPE, _PROJECT_MARKER)
def _appended_project_marker_index(segment: str) -> int:
marker_index = segment.rfind(_PROJECT_MARKER)
while marker_index >= 0 and segment.startswith(_PROJECT_MARKER_ESCAPE, marker_index):
marker_index = segment.rfind(_PROJECT_MARKER, 0, marker_index)
return marker_index
def normalize_project_name(project_name: Any) -> Optional[str]:
"""Return a trimmed project name, or None when empty/invalid."""
if not isinstance(project_name, str):
return None
normalized = " ".join(project_name.strip().split())
return normalized or None
def slugify_project_name(project_name: Any) -> Optional[str]:
"""Convert a project name into a filesystem-safe suffix."""
normalized = normalize_project_name(project_name)
if normalized is None:
return None
slug = _INVALID_SEGMENT_CHARS.sub("-", normalized).strip("-._")
if not slug:
return None
return slug.lower()
def build_default_output_dir_name(
model_name: str,
project_name: Any = None,
*,
timestamp: Optional[int] = None,
) -> str:
"""Build the default training output folder name."""
from utils.paths import default_run_dir_name
timestamp_part = str(int(time.time() if timestamp is None else timestamp))
timestamp_suffix = f"_{timestamp_part}"
model_segment = _escape_project_marker(default_run_dir_name(model_name))
project_slug = slugify_project_name(project_name)
if not project_slug:
max_model_chars = _MAX_RUN_DIR_NAME_CHARS - len(timestamp_suffix)
model_segment = _trim_segment(model_segment, max_model_chars) or "model"
return f"{model_segment}{timestamp_suffix}"
max_project_chars = (
_MAX_RUN_DIR_NAME_CHARS - len("model") - len(_PROJECT_MARKER) - len(timestamp_suffix)
)
project_slug = _trim_segment(project_slug, max_project_chars) or "project"
project_suffix = f"{_PROJECT_MARKER}{project_slug}{timestamp_suffix}"
max_model_chars = _MAX_RUN_DIR_NAME_CHARS - len(project_suffix)
model_segment = _trim_segment(model_segment, max_model_chars) or "model"
return f"{model_segment}{project_suffix}"
def model_segment_from_default_output_dir_name(output_dir_name: str) -> Optional[str]:
"""Return the encoded model segment from a default run folder name."""
parts = str(output_dir_name or "").rsplit("_", 1)
if len(parts) != 2 or not parts[1].isdigit():
return None
model_segment = parts[0]
marker_index = _appended_project_marker_index(model_segment)
if marker_index >= 0:
model_segment = model_segment[:marker_index]
model_segment = _unescape_project_marker(model_segment)
return model_segment or None
def extract_project_name(config: Any) -> Optional[str]:
"""Read and normalize a project name from a stored config dict."""
if not isinstance(config, dict):
return None
return normalize_project_name(config.get("project_name"))

View file

@ -123,6 +123,7 @@ import {
deleteTrainingRun,
emitTrainingRunDeleted,
emitTrainingRunUpdated,
getTrainingRunDisplayTitle,
removeTrainingUnloadGuard,
renameTrainingRun,
useTrainingCompletionWatch,
@ -592,7 +593,7 @@ export function AppSidebar() {
setRenamingTarget({ kind: "chat", item, current: item.title });
}
function openRenameRun(run: TrainingRunSummary) {
const current = run.display_name ?? run.model_name;
const current = getTrainingRunDisplayTitle(run);
setRenameDraft(current);
setRenamingTarget({ kind: "run", run, current });
}
@ -1377,7 +1378,7 @@ export function AppSidebar() {
aria-hidden
/>
<span className="truncate">
{run.display_name ?? run.model_name}
{getTrainingRunDisplayTitle(run)}
</span>
<span className="ml-auto mr-0.5 shrink-0 text-[10px] text-muted-foreground">
{formatRelativeShort(run.started_at)}
@ -1653,8 +1654,7 @@ export function AppSidebar() {
renderEmphasizedTranslation(
t,
"shell.dialog.deleteRun.description",
confirmingDelete.run.display_name ??
confirmingDelete.run.model_name,
getTrainingRunDisplayTitle(confirmingDelete.run),
)
) : confirmingDelete?.kind === "chat" ? (
renderEmphasizedTranslation(

View file

@ -75,16 +75,35 @@ import { exportTourSteps } from "./tour";
const SEARCH_INPUT_REASONS = new Set(["input-change", "input-paste", "input-clear"]);
type SourceTab = "local" | "checkpoint" | "hf";
type SourceMode = "checkpoint" | "model";
function safePathSegment(
value: string | null | undefined,
fallback = "model",
maxLength = 250,
): string {
const safe = (value ?? "")
.replace(/[^a-zA-Z0-9._-]/g, "-")
.replace(/^[._-]+|[._-]+$/g, "")
.slice(0, maxLength)
.replace(/[._-]+$/g, "");
return safe || fallback;
}
function buildRelativeSaveDirectory(
exportMethod: ExportMethod | null,
sourceMode: SourceMode,
sourceBaseModelName: string,
selectedModelIdx: string | null,
checkpoint: string | null,
): string {
if (exportMethod === "gguf") {
return `${(sourceBaseModelName.split("/").pop() ?? selectedModelIdx ?? "model")
.replace(/[^a-zA-Z0-9._-]/g, "-")}-GGUF`;
const rawName =
sourceMode === "checkpoint"
? checkpoint ?? selectedModelIdx ?? sourceBaseModelName
: sourceBaseModelName;
return `${safePathSegment(rawName)}-GGUF`;
}
return `${selectedModelIdx ?? "model"}/${checkpoint}`;
}
@ -125,9 +144,7 @@ export function ExportPage() {
const [selectedModelIdx, setSelectedModelIdx] = useState<string | null>(null);
const [checkpoint, setCheckpoint] = useState<string | null>(null);
const [sourceMode, setSourceMode] = useState<"checkpoint" | "model">(
"checkpoint",
);
const [sourceMode, setSourceMode] = useState<SourceMode>("checkpoint");
const [modelSource, setModelSource] = useState<"hf" | "local">("hf");
const [modelInput, setModelInput] = useState("");
const [selectedSourceModel, setSelectedSourceModel] = useState<string | null>(
@ -449,6 +466,7 @@ export function ExportPage() {
const defaultSaveDirectory = useMemo(() => {
const relative = buildRelativeSaveDirectory(
exportMethod,
sourceMode,
sourceBaseModelName,
selectedModelIdx,
checkpoint,

View file

@ -7,6 +7,7 @@ import {
FieldLegend,
FieldSet,
} from "@/components/ui/field";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
@ -59,6 +60,8 @@ function stepLR(value: number, direction: 1 | -1): number {
export function HyperparametersStep() {
const {
trainingMethod,
projectName,
setProjectName,
maxSteps,
setMaxSteps,
epochs,
@ -79,6 +82,8 @@ export function HyperparametersStep() {
} = useTrainingConfigStore(
useShallow((s) => ({
trainingMethod: s.trainingMethod,
projectName: s.projectName,
setProjectName: s.setProjectName,
maxSteps: s.maxSteps,
setMaxSteps: s.setMaxSteps,
epochs: s.epochs,
@ -125,6 +130,26 @@ export function HyperparametersStep() {
<FieldSet>
<FieldLegend variant="label">Choose your training parameters</FieldLegend>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<div className="flex items-center justify-between">
<FieldLabel className="flex items-center gap-1.5 !text-sm text-muted-foreground">
Project Name
<span className="text-xs font-normal text-muted-foreground/70">
Optional
</span>
</FieldLabel>
</div>
<Input
value={projectName || ""}
onChange={(e) => setProjectName(e.target.value)}
placeholder="customer-support-lora"
maxLength={80}
/>
<p className="text-xs text-muted-foreground">
Used in training output folder names, export defaults, and history.
</p>
</div>
<div
key={useEpochs ? "epochs" : "steps"}
className="flex flex-col gap-2 animate-in fade-in-1 slide-in-from-bottom-1 duration-200"

View file

@ -50,6 +50,7 @@ export function SummaryStep() {
const {
modelType,
selectedModel,
projectName,
trainingMethod,
datasetSource,
datasetFormat,
@ -68,6 +69,7 @@ export function SummaryStep() {
({
modelType,
selectedModel,
projectName,
trainingMethod,
datasetSource,
datasetFormat,
@ -84,6 +86,7 @@ export function SummaryStep() {
}) => ({
modelType,
selectedModel,
projectName,
trainingMethod,
datasetSource,
datasetFormat,
@ -152,6 +155,7 @@ export function SummaryStep() {
<Separator className="my-2" />
<div className="space-y-1 text-sm">
<Row label="Type" value={modelType} capitalize />
<Row label="Project" value={projectName || "--"} />
<Row label="Method" value={trainingMethodLabel} />
</div>
</CardContent>

View file

@ -78,6 +78,7 @@ function mapToViewData(
error: run.status === "error" ? run.error_message : null,
isTrainingRunning: false,
modelName: run.display_name ?? run.model_name,
projectName: run.project_name,
trainingMethod: parseBackendTrainingMethod(
detail.config?.training_type,
detail.config?.load_in_4bit,

View file

@ -15,6 +15,8 @@ import { Button } from "@/components/ui/button";
import type { TrainingRunSummary } from "@/features/training";
import {
deleteTrainingRun,
getTrainingRunDisplayTitle,
getTrainingRunModelSubtitle,
emitTrainingRunDeleted,
listTrainingRuns,
onTrainingRunDeleted,
@ -387,6 +389,12 @@ export function HistoryCardGrid({
const isRunning = run.status === "running";
const canResume = run.can_resume && !wasContinued;
const isResuming = resumeTarget === run.id;
const title = getTrainingRunDisplayTitle(run);
const modelSubtitle = getTrainingRunModelSubtitle(run);
const projectSubtitle =
run.project_name && title !== run.project_name ? run.project_name : null;
// Backend /p ref + its capability token. Both are required: the link
// is useless (404s) without the signature, so don't offer to copy it.
const canCopyPreview = !!run.preview_ref && !!run.preview_sig;
@ -476,16 +484,16 @@ export function HistoryCardGrid({
<div className="min-w-0">
<p
className="truncate text-sm font-medium"
title={run.display_name ?? run.model_name}
title={title}
>
{run.display_name ?? run.model_name}
{title}
</p>
{run.display_name && (
{modelSubtitle && (
<p
className="truncate text-xs text-muted-foreground"
title={run.model_name}
title={modelSubtitle}
>
{run.model_name}
{modelSubtitle}
</p>
)}
<p
@ -494,6 +502,14 @@ export function HistoryCardGrid({
>
{run.dataset_name}
</p>
{projectSubtitle && (
<p
className="truncate text-xs text-muted-foreground/80"
title={projectSubtitle}
>
{projectSubtitle}
</p>
)}
</div>
{run.loss_sparkline && run.loss_sparkline.length >= 2 && (
<div className={cn((canResume || canCopyPreview) && "h-7 overflow-hidden")}>

View file

@ -33,6 +33,8 @@ export function LiveTrainingView(): ReactElement {
evalEnabled: state.evalEnabled,
outputDir: state.outputDir,
isTrainingRunning: state.isTrainingRunning,
startModelName: state.startModelName,
startProjectName: state.startProjectName,
lossHistory: state.lossHistory,
lrHistory: state.lrHistory,
gradNormHistory: state.gradNormHistory,
@ -45,10 +47,16 @@ export function LiveTrainingView(): ReactElement {
const config = useTrainingConfigStore(
useShallow((state) => ({
selectedModel: state.selectedModel,
projectName: state.projectName,
trainingMethod: state.trainingMethod,
})),
);
const activeProjectName =
runtime.startProjectName !== null
? runtime.startProjectName.trim() || null
: (config.projectName || "").trim() || null;
const viewData: TrainingViewData = {
phase: runtime.phase,
currentStep: runtime.currentStep,
@ -66,7 +74,8 @@ export function LiveTrainingView(): ReactElement {
message: runtime.message,
error: runtime.error,
isTrainingRunning: runtime.isTrainingRunning,
modelName: config.selectedModel ?? "",
modelName: runtime.startModelName ?? config.selectedModel ?? "",
projectName: activeProjectName,
trainingMethod: config.trainingMethod ?? "",
lossHistory: runtime.lossHistory,
lrHistory: runtime.lrHistory,

View file

@ -229,6 +229,24 @@ export function ParamsSection(): ReactElement {
: "h-studio-config-column"} duration-150`}
>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-2">
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
{t("studio.params.projectName")}
<span className="text-[10px] font-normal text-muted-foreground/70">
{t("studio.params.optional")}
</span>
</span>
<Input
value={store.projectName || ""}
onChange={(event) => store.setProjectName(event.target.value)}
placeholder="customer-support-lora"
maxLength={80}
/>
<p className="text-[10px] text-muted-foreground">
{t("studio.params.projectNameDescription")}
</p>
</div>
{/* Max Steps / Epochs */}
<div className="flex flex-col gap-2">
<div

View file

@ -270,6 +270,11 @@ export function ProgressSection({
>
{t(phaseLabelKeys[data.phase])}
</span>
{data.projectName && (
<span className="rounded-full border border-border/60 px-2.5 py-1 text-[10px] font-medium text-foreground/80">
{data.projectName}
</span>
)}
<span className="text-[10px] tabular-nums text-muted-foreground">
{t("studio.progress.epoch", {
value: formatNumber(data.currentEpoch, 2),
@ -290,7 +295,7 @@ export function ProgressSection({
</span>
<span>{pct}%</span>
</div>
<Progress value={pct} className="h-2 bg-foreground/[0.05]" />
<Progress value={pct} className="h-2 bg-foreground/5" />
</div>
{!isHistorical && (
@ -307,7 +312,12 @@ export function ProgressSection({
</p>
)}
<div className="grid gap-x-4 gap-y-3 pt-1 sm:grid-cols-2 xl:grid-cols-5">
<div
className={cn(
"grid gap-x-4 gap-y-3 pt-1 sm:grid-cols-2",
data.projectName ? "xl:grid-cols-6" : "xl:grid-cols-5",
)}
>
<MetricStat
label={t("studio.progress.loss")}
valueClassName="text-2xl font-bold tracking-tight"
@ -318,6 +328,11 @@ export function ProgressSection({
<MetricStat label={t("studio.progress.gradNorm")}>
{formatNumber(stoppedGradNorm, 3)}
</MetricStat>
{data.projectName && (
<MetricStat label={t("studio.progress.project")} valueClassName="truncate">
{data.projectName}
</MetricStat>
)}
<MetricStat label={t("studio.progress.model")} valueClassName="truncate">
{data.modelName || "--"}
</MetricStat>

View file

@ -73,6 +73,7 @@ export function buildTrainingStartPayload(
return {
model_name: config.selectedModel ?? "",
project_name: (config.projectName || "").trim() || null,
training_type: toBackendTrainingType(config.trainingMethod),
hf_token: config.hfToken.trim() || null,
load_in_4bit: (adapterMethod && isQloraMethod) || (isCpt && isFourBitModel),

View file

@ -60,6 +60,7 @@ export function useTrainingActions() {
config.selectedModel ?? null,
getHfDatasetName(config),
false,
config.projectName || "",
);
runtimeStore.setStarting(true);
@ -152,7 +153,12 @@ export function useTrainingActions() {
// Re-read config after potential store updates from dataset check
const payload = buildTrainingStartPayload(useTrainingConfigStore.getState());
runtimeStore.setStartResources(payload.model_name, payload.hf_dataset, false);
runtimeStore.setStartResources(
payload.model_name,
payload.hf_dataset,
false,
payload.project_name ?? "",
);
const response = await startTraining(payload);
if (response.status === "error") {
@ -196,7 +202,7 @@ export function useTrainingActions() {
const resumeTrainingRunFromHistory = useCallback(async (runId: string): Promise<boolean> => {
const runtimeStore = useTrainingRuntimeStore.getState();
runtimeStore.setStartError(null);
runtimeStore.setStartResources(null, null, true);
runtimeStore.setStartResources(null, null, true, null);
runtimeStore.setStarting(true);
try {
@ -220,7 +226,12 @@ export function useTrainingActions() {
resume_from_checkpoint: outputDir,
} as TrainingStartRequest;
runtimeStore.setStartResources(payload.model_name, payload.hf_dataset, true);
runtimeStore.setStartResources(
payload.model_name,
payload.hf_dataset,
true,
payload.project_name ?? "",
);
// Resume goes straight to startTraining, so it runs the same consent gate as a
// fresh start; otherwise a resumed custom-code run hits the worker block with no dialog.

View file

@ -7,6 +7,11 @@ export {
useTrainingRuntimeStore,
} from "./stores/training-runtime-store";
export { useTrainingActions } from "./hooks/use-training-actions";
export {
getTrainingRunDisplayTitle,
getTrainingRunModelSubtitle,
} from "./lib/run-display";
export { useTrainingHistorySidebarItems } from "./hooks/use-training-history-sidebar";
export { useTrainingRuntimeLifecycle } from "./hooks/use-training-runtime-lifecycle";
export { useTrainingCompletionWatch } from "./hooks/use-training-completion-watch";

View file

@ -0,0 +1,24 @@
// 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 type { TrainingRunSummary } from "../types/history";
type TrainingRunTitleFields = Pick<
TrainingRunSummary,
"display_name" | "project_name" | "model_name"
>;
function nonEmpty(value: string | null | undefined): string | null {
const trimmed = value?.trim();
return trimmed ? trimmed : null;
}
export function getTrainingRunDisplayTitle(run: TrainingRunTitleFields): string {
return nonEmpty(run.display_name) ?? nonEmpty(run.project_name) ?? run.model_name;
}
export function getTrainingRunModelSubtitle(
run: TrainingRunTitleFields,
): string | null {
return getTrainingRunDisplayTitle(run) === run.model_name ? null : run.model_name;
}

View file

@ -58,6 +58,7 @@ const initialState: TrainingConfigState = {
currentStep: MIN_STEP,
modelType: null,
selectedModel: null,
projectName: "",
trainingMethod: "qlora",
hfToken: "",
datasetSource: "huggingface",
@ -613,6 +614,7 @@ export const useTrainingConfigStore = create<TrainingConfigStore>()(
if (state.modelDefaultsAppliedFor === state.selectedModel) return;
void loadAndApplyModelDefaults(state.selectedModel);
},
setProjectName: (projectName) => set({ projectName }),
setTrainingMethod: (trainingMethod) => {
const state = get();
set(

View file

@ -24,6 +24,7 @@ const initialState: TrainingRuntimeState = {
startError: null,
startModelName: null,
startDatasetName: null,
startProjectName: null,
startFromResume: false,
sseConnected: false,
firstStepReceived: false,
@ -125,8 +126,12 @@ export const useTrainingRuntimeStore = create<TrainingRuntimeStore>()((set) => (
setHasHydrated: (value) => set({ hasHydrated: value }),
setStarting: (value) => set({ isStarting: value }),
setStartError: (value) => set({ startError: value }),
setStartResources: (startModelName, startDatasetName, startFromResume = false) =>
set({ startModelName, startDatasetName, startFromResume }),
setStartResources: (
startModelName,
startDatasetName,
startFromResume = false,
startProjectName = null,
) => set({ startModelName, startDatasetName, startProjectName, startFromResume }),
setSseConnected: (value) => set({ sseConnected: value }),
setLastEventId: (value) => set({ lastEventId: value }),

View file

@ -5,6 +5,7 @@ import type { S3Config } from "@/types/training";
export interface TrainingStartRequest {
model_name: string;
project_name: string | null;
training_type: string;
hf_token: string | null;
load_in_4bit: boolean;

View file

@ -21,6 +21,7 @@ export interface TrainingConfigState {
currentStep: StepNumber;
modelType: ModelType | null;
selectedModel: string | null;
projectName: string;
trainingMethod: TrainingMethod;
hfToken: string;
datasetSource: DatasetSource;
@ -95,6 +96,7 @@ export interface TrainingConfigActions {
prevStep: () => void;
setModelType: (type: ModelType) => void;
setSelectedModel: (model: string | null) => void;
setProjectName: (value: string) => void;
ensureModelDefaultsLoaded: () => void;
ensureDatasetChecked: () => void;
setTrainingMethod: (method: TrainingMethod) => void;

View file

@ -5,6 +5,7 @@ export interface TrainingRunSummary {
id: string;
status: "running" | "completed" | "stopped" | "error";
model_name: string;
project_name: string | null;
dataset_name: string;
display_name: string | null;
started_at: string;

View file

@ -83,6 +83,7 @@ export interface TrainingRuntimeState {
startError: string | null;
startModelName: string | null;
startDatasetName: string | null;
startProjectName: string | null;
startFromResume: boolean;
sseConnected: boolean;
firstStepReceived: boolean;
@ -121,6 +122,7 @@ export interface TrainingRuntimeActions {
modelName: string | null,
datasetName: string | null,
fromResume?: boolean,
projectName?: string | null,
) => void;
setSseConnected: (value: boolean) => void;
setLastEventId: (value: number | null) => void;
@ -160,6 +162,7 @@ export interface TrainingViewData {
// Config summary
modelName: string;
projectName: string | null;
trainingMethod: string;
// Time-series (for ChartsSection)

View file

@ -609,6 +609,10 @@ export const en = {
params: {
title: "Parameters",
description: "Configure training hyperparameters",
projectName: "Project Name",
optional: "Optional",
projectNameDescription:
"Used in training output folder names, export defaults, and history.",
loraSettings: "LoRA Settings",
trainingHyperparameters: "Training Hyperparameters",
maxSteps: "Max Steps",
@ -850,6 +854,7 @@ export const en = {
loss: "Loss",
lr: "LR",
gradNorm: "Grad Norm",
project: "Project",
model: "Model",
method: "Method",
elapsed: "Elapsed: {value}",

View file

@ -541,6 +541,9 @@ export const zhCN = {
params: {
title: "参数",
description: "配置训练超参数",
projectName: "项目名称",
optional: "可选",
projectNameDescription: "用于训练输出文件夹名称、导出默认值和历史记录。",
loraSettings: "LoRA 设置",
trainingHyperparameters: "训练超参数",
maxSteps: "最大步数",
@ -766,6 +769,7 @@ export const zhCN = {
loss: "Loss",
lr: "LR",
gradNorm: "梯度范数",
project: "项目",
model: "模型",
method: "方法",
elapsed: "已用时间:{value}",