(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:
parent
f80e66ea34
commit
11469a60fe
31 changed files with 804 additions and 29 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# ("512") 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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
256
studio/backend/tests/test_checkpoints_scan.py
Normal file
256
studio/backend/tests/test_checkpoints_scan.py
Normal 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"
|
||||
103
studio/backend/tests/test_training_runs.py
Normal file
103
studio/backend/tests/test_training_runs.py
Normal 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
|
||||
|
|
@ -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 ---
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 :]
|
||||
|
|
|
|||
104
studio/backend/utils/training_runs.py
Normal file
104
studio/backend/utils/training_runs.py
Normal 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"))
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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")}>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
24
studio/frontend/src/features/training/lib/run-display.ts
Normal file
24
studio/frontend/src/features/training/lib/run-display.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue