merge nightly

This commit is contained in:
Shine1i 2026-02-15 14:52:07 +01:00
commit ba09bbfaba
64 changed files with 1391 additions and 130 deletions

145
README.md
View file

@ -1 +1,144 @@
# new-ui-prototype
<p align="center">
<img src="https://raw.githubusercontent.com/unslothai/unsloth/main/images/unsloth%20logo%20white%20text.png" alt="Unsloth Studio" width="400"/>
</p>
<h3 align="center">🦥 Unsloth Studio</h3>
<p align="center">
A modern, full-stack web interface for fine-tuning, managing, and chatting with large language models — locally or in the cloud.
</p>
<p align="center">
<a href="#features">Features</a> •
<a href="#quick-start">Quick Start</a> •
<a href="#api-reference">API Reference</a> •
<a href="#project-structure">Project Structure</a>
</p>
---
## Features
| Area | Capabilities |
|---|---|
| **Training** | Configure and launch LoRA / QLoRA fine-tuning jobs with real-time SSE progress streaming, live loss charts, and one-click stop / resume |
| **Model Management** | Browse, load, and manage Hugging Face hub models and local checkpoints |
| **Inference** | Interactive chat playground for testing fine-tuned models |
| **Dataset Tools** | Upload, preview, and prepare datasets (JSON, CSV, Parquet, PDF, DOCX) |
| **Export** | Export & push trained adapters to the Hugging Face Hub |
| **Auth** | Token-based authentication with JWT access / refresh flow and first-time setup token |
## Quick Start
### One-command setup
```bash
bash setup.sh
```
This script will:
1. Install **Node.js ≥ 20** via nvm (if needed)
2. Build the frontend to `studio/frontend/dist`
3. Create a Python virtual environment and install all dependencies (including `unsloth`)
4. Register a convenient `unsloth-ui` shell alias
### Launch the studio
```bash
# After setup, open a new terminal (or source ~/.bashrc), then:
unsloth-ui -H 0.0.0.0 -p 8000
```
On **first launch**, a one-time setup token is printed to the console. Use it in the browser to create your admin account.
## API Reference
All endpoints require a valid JWT `Authorization: Bearer <token>` header (except `/api/auth/*` and `/api/health`).
| Method | Endpoint | Description |
|---|---|---|
| `GET` | `/api/health` | Health check |
| `GET` | `/api/system` | System info (GPU, CPU, memory) |
| `POST` | `/api/auth/signup` | Create account (requires setup token on first run) |
| `POST` | `/api/auth/login` | Login and receive JWT tokens |
| `POST` | `/api/auth/refresh` | Refresh an expired access token |
| `GET` | `/api/auth/status` | Check if auth is initialized |
| `POST` | `/api/train/start` | Start a training job |
| `POST` | `/api/train/stop` | Stop a running training job |
| `POST` | `/api/train/reset` | Reset training state |
| `GET` | `/api/train/status` | Get current training status |
| `GET` | `/api/train/metrics` | Get training metrics (loss, LR, steps) |
| `GET` | `/api/train/stream` | SSE stream of real-time training progress |
| `GET` | `/api/models/` | List available models |
| `POST` | `/api/inference/chat` | Send a chat message for inference |
| `GET` | `/api/datasets/` | List / manage datasets |
> Full interactive docs are available at `/docs` (Swagger UI) and `/redoc` when the server is running.
## CLI Commands
The Unsloth CLI (`cli.py`) provides the following commands:
```
Usage: cli.py [COMMAND]
Commands:
train Fine-tune a model
inference Run inference on a trained model
export Export a trained adapter
list-checkpoints List saved checkpoints
ui Launch the Unsloth Studio web UI
studio Launch the studio (alias)
```
## Project Structure
```
new-ui-prototype/
├── cli.py # CLI entry point
├── cli/ # Typer CLI commands
│ └── commands/
│ ├── train.py
│ ├── inference.py
│ ├── export.py
│ ├── ui.py
│ └── studio.py
├── setup.sh # One-command bootstrap script
└── studio/
├── backend/
│ ├── main.py # FastAPI app & middleware
│ ├── run.py # Server launcher (uvicorn)
│ ├── auth/ # Auth storage & JWT logic
│ ├── routes/ # API route handlers
│ │ ├── training.py
│ │ ├── models.py
│ │ ├── inference.py
│ │ ├── datasets.py
│ │ └── auth.py
│ ├── models/ # Pydantic request/response schemas
│ ├── core/ # Training engine & config
│ ├── utils/ # Hardware detection, helpers
│ └── requirements.txt
├── frontend/
│ ├── src/
│ │ ├── features/ # Feature modules
│ │ │ ├── auth/ # Login / signup flow
│ │ │ ├── training/ # Training config & monitoring
│ │ │ ├── studio/ # Main studio workspace
│ │ │ ├── chat/ # Inference chat UI
│ │ │ ├── export/ # Model export flow
│ │ │ └── onboarding/# Onboarding wizard
│ │ ├── components/ # Shared UI components (shadcn)
│ │ ├── hooks/ # Custom React hooks
│ │ ├── stores/ # Zustand state stores
│ │ └── types/ # TypeScript type definitions
│ ├── package.json
│ └── vite.config.ts
└── tests/ # Backend test suite
```
## License
This project is licensed under the [GNU Affero General Public License v3.0 (AGPL-3.0)](https://www.gnu.org/licenses/agpl-3.0.html).
Copyright © 2026 Unsloth AI.

View file

@ -45,3 +45,9 @@ logging:
tensorboard_dir: "runs"
log_frequency: 10
inference:
temperature: 0.7
top_p: 0.95
top_k: -1
min_p: 0.01

View file

@ -1,6 +1,7 @@
# Model defaults for unsloth/ERNIE-4.5-VL-28B-A3B-PT
# Based on ERNIE_4_5_VL_28B_A3B_PT_Vision.ipynb
# Also applies to: unsloth/ERNIE-4.5-VL-28B-A3B-PT
# added inference parameters from unsloth notebook
training:
max_seq_length: 2048
@ -45,3 +46,7 @@ logging:
tensorboard_dir: "runs"
log_frequency: 10
inference:
temperature: 1.5
min_p: 0.1

View file

@ -1,6 +1,7 @@
# Model defaults for unsloth/codegemma-7b-bnb-4bit
# Based on CodeGemma_(7B)-Conversational.ipynb
# Also applies to: unsloth/codegemma-7b, google/codegemma-7b
# added inference parameters from Ollama
training:
max_seq_length: 4096
@ -44,3 +45,7 @@ logging:
enable_tensorboard: false
tensorboard_dir: "runs"
log_frequency: 10
inference:
temperature: 0
top_p: 0.9

View file

@ -1,6 +1,7 @@
# Model defaults for unsloth/functiongemma-270m-it
# Based on FunctionGemma_(270M).ipynb
# Also applies to: unsloth/functiongemma-270m-it-unsloth-bnb-4bit, google/functiongemma-270m-it, unsloth/functiongemma-270m-it-unsloth-bnb-4bit
# added inference parameters from unsloth guides
training:
max_seq_length: 4096
@ -45,3 +46,9 @@ logging:
tensorboard_dir: "runs"
log_frequency: 10
inference:
temperature: 1.0
top_k: 64
top_p: 0.95
min_p: 0.0

View file

@ -1,6 +1,7 @@
# Model defaults for unsloth/gemma-3-270m-it
# Based on Gemma3_(270M).ipynb
# Also applies to: unsloth/gemma-3-270m-it-unsloth-bnb-4bit, google/gemma-3-270m-it, unsloth/gemma-3-270m-it-bnb-4bit
# added inference parameters from unsloth guides
training:
max_seq_length: 2048
@ -45,3 +46,9 @@ logging:
tensorboard_dir: "runs"
log_frequency: 10
inference:
temperature: 1.0
top_k: 64
top_p: 0.95
min_p: 0.0

View file

@ -1,6 +1,7 @@
# Model defaults for unsloth/gemma-3-27b-it
# Based on Gemma3_(27B)_A100-Conversational.ipynb
# Also applies to: unsloth/gemma-3-27b-it-unsloth-bnb-4bit, google/gemma-3-27b-it, unsloth/gemma-3-27b-it-bnb-4bit
# added inference parameters from unsloth guides
training:
max_seq_length: 2048
@ -39,3 +40,9 @@ logging:
tensorboard_dir: "runs"
log_frequency: 10
inference:
temperature: 1.0
top_k: 64
top_p: 0.95
min_p: 0.0

View file

@ -1,6 +1,7 @@
# Model defaults for unsloth/gemma-3-4b-it
# Based on Gemma3_(4B).ipynb
# Also applies to: unsloth/gemma-3-4b-it-unsloth-bnb-4bit, google/gemma-3-4b-it, unsloth/gemma-3-4b-it-bnb-4bit
# added inference parameters from unsloth guides
training:
max_seq_length: 2048
@ -39,3 +40,9 @@ logging:
tensorboard_dir: "runs"
log_frequency: 10
inference:
temperature: 1.0
top_k: 64
top_p: 0.95
min_p: 0.0

View file

@ -1,6 +1,7 @@
# Model defaults for unsloth/gemma-3-4b-pt
# Based on Gemma3_(4B)-Vision.ipynb
# Also applies to: unsloth/gemma-3-4b-pt-unsloth-bnb-4bit, google/gemma-3-4b-pt, unsloth/gemma-3-4b-pt-bnb-4bit
# added inference parameters from unsloth guides
training:
max_seq_length: 2048
@ -39,3 +40,9 @@ logging:
tensorboard_dir: "runs"
log_frequency: 10
inference:
temperature: 1.0
top_k: 64
top_p: 0.95
min_p: 0.0

View file

@ -1,6 +1,7 @@
# Model defaults for unsloth/gemma-3n-E4B-it
# Based on Gemma3N_(4B)-Conversational.ipynb
# Also applies to: unsloth/gemma-3n-E4B-it-unsloth-bnb-4bit, google/gemma-3n-E4B-it, unsloth/gemma-3n-E4B-it-unsloth-bnb-4bit
# added inference parameters from unsloth guides
training:
max_seq_length: 1024
@ -39,3 +40,9 @@ logging:
tensorboard_dir: "runs"
log_frequency: 10
inference:
temperature: 1.0
top_k: 64
top_p: 0.95
min_p: 0.0

View file

@ -1,6 +1,7 @@
# Model defaults for unsloth/gemma-3n-E4B
# Based on Gemma3N_(4B)-Vision.ipynb
# Also applies to: unsloth/gemma-3n-E4B-unsloth-bnb-4bit, google/gemma-3n-E4B
# added inference parameters from unsloth guides
training:
max_seq_length: 2048
@ -39,3 +40,9 @@ logging:
tensorboard_dir: "runs"
log_frequency: 10
inference:
temperature: 1.0
top_k: 64
top_p: 0.95
min_p: 0.0

View file

@ -1,6 +1,7 @@
# Model defaults for unsloth/gpt-oss-120b
# Based on gpt-oss-(120B)_A100-Fine-tuning.ipynb
# Also applies to: openai/gpt-oss-120b, unsloth/gpt-oss-120b-unsloth-bnb-4bit
# added inference parameters from unsloth guides
training:
max_seq_length: 4096
@ -45,3 +46,8 @@ logging:
tensorboard_dir: "runs"
log_frequency: 10
inference:
temperature: 1.0
top_p: 1.0
top_k: 0

View file

@ -1,6 +1,7 @@
# Model defaults for unsloth/gpt-oss-20b
# Based on gpt-oss-(20B)-Fine-tuning.ipynb
# Also applies to: openai/gpt-oss-20b, unsloth/gpt-oss-20b-unsloth-bnb-4bit, unsloth/gpt-oss-20b-BF16
# added inference parameters from unsloth guides
training:
max_seq_length: 1024
@ -45,3 +46,8 @@ logging:
tensorboard_dir: "runs"
log_frequency: 10
inference:
temperature: 1.0
top_p: 1.0
top_k: 0

View file

@ -1,6 +1,7 @@
# Model defaults for unsloth/granite-4.0-350m
# Based on Granite4.0_350M.ipynb
# Also applies to: ibm-granite/granite-4.0-350m, unsloth/granite-4.0-350m-bnb-4bit
# added inference parameters from unsloth guides
training:
max_seq_length: 2048
@ -47,3 +48,8 @@ logging:
tensorboard_dir: "runs"
log_frequency: 10
inference:
temperature: 0.0
top_p: 1.0
top_k: 0

View file

@ -1,6 +1,7 @@
# Model defaults for unsloth/granite-4.0-h-micro
# Based on Granite4.0.ipynb
# Also applies to: ibm-granite/granite-4.0-h-micro, unsloth/granite-4.0-h-micro-bnb-4bit, unsloth/granite-4.0-h-micro-unsloth-bnb-4bit
# added inference parameters from unsloth guides
training:
max_seq_length: 2048
@ -47,3 +48,8 @@ logging:
tensorboard_dir: "runs"
log_frequency: 10
inference:
temperature: 0.0
top_p: 1.0
top_k: 0

View file

@ -1,6 +1,7 @@
# Model defaults for unsloth/Llama-3.2-11B-Vision-Instruct
# Based on Llama3.2_(11B)-Vision.ipynb
# Also applies to: unsloth/Llama-3.2-11B-Vision-Instruct-unsloth-bnb-4bit, meta-llama/Llama-3.2-11B-Vision-Instruct, unsloth/Llama-3.2-11B-Vision-Instruct-bnb-4bit
# added inference parameters from unsloth notebook
training:
max_seq_length: 2048
@ -39,3 +40,7 @@ logging:
tensorboard_dir: "runs"
log_frequency: 10
inference:
temperature: 1.5
min_p: 0.1

View file

@ -1,6 +1,7 @@
# Model defaults for unsloth/Llama-3.2-3B-Instruct
# Based on Llama3.2_(1B_and_3B)-Conversational.ipynb
# Also applies to: unsloth/Llama-3.2-3B-Instruct-unsloth-bnb-4bit, meta-llama/Llama-3.2-3B-Instruct, unsloth/Llama-3.2-3B-Instruct-bnb-4bit, RedHatAI/Llama-3.2-3B-Instruct-FP8, unsloth/Llama-3.2-3B-Instruct-FP8-Block, unsloth/Llama-3.2-3B-Instruct-FP8-Dynamic
# added inference parameters from unsloth notebook
training:
max_seq_length: 2048
@ -45,3 +46,7 @@ logging:
tensorboard_dir: "runs"
log_frequency: 10
inference:
temperature: 1.5
min_p: 0.1

View file

@ -1,6 +1,7 @@
# Model defaults for unsloth/Llama-3.3-70B-Instruct
# Based on Llama3.3_(70B)_A100-Conversational.ipynb
# Also applies to: unsloth/Llama-3.3-70B-Instruct-unsloth-bnb-4bit, meta-llama/Llama-3.3-70B-Instruct, unsloth/Llama-3.3-70B-Instruct-bnb-4bit, RedHatAI/Llama-3.3-70B-Instruct-FP8, unsloth/Llama-3.3-70B-Instruct-FP8-Block, unsloth/Llama-3.3-70B-Instruct-FP8-Dynamic
# added inference parameters from unsloth notebook
training:
max_seq_length: 2048
@ -45,3 +46,7 @@ logging:
tensorboard_dir: "runs"
log_frequency: 10
inference:
temperature: 1.5
min_p: 0.1

View file

@ -1,6 +1,7 @@
# Model defaults for unsloth/Llasa-3B
# Based on Llasa_TTS_(3B).ipynb and Llasa_TTS_(1B).ipynb
# Also applies to: HKUSTAudio/Llasa-1B
# added inference parameters from unsloth notebook
training:
max_seq_length: 2048
@ -40,3 +41,7 @@ logging:
tensorboard_dir: "runs"
log_frequency: 10
inference:
temperature: 1.2
top_p: 1.2

View file

@ -1,6 +1,7 @@
# Model defaults for unsloth/Magistral-Small-2509
# Based on Magistral_(24B)-Reasoning-Conversational.ipynb
# Also applies to: mistralai/Magistral-Small-2509, unsloth/Magistral-Small-2509-bnb-4bit
# added inference parameters from unsloth guides
training:
max_seq_length: 2048
@ -45,3 +46,8 @@ logging:
tensorboard_dir: "runs"
log_frequency: 10
inference:
temperature: 0.7
min_p: 0.01
top_p: 0.95

View file

@ -1,6 +1,7 @@
# Model defaults for unsloth/Ministral-3-3B-Instruct-2512
# Based on Ministral_3_VL_(3B)_Vision.ipynb
# Also applies to: unsloth/Ministral-3-3B-Instruct-2512
# added inference parameters from unsloth guides
training:
max_seq_length: 2048
@ -45,3 +46,7 @@ logging:
tensorboard_dir: "runs"
log_frequency: 10
inference:
temperature: 0.15
top_p: default

View file

@ -1,6 +1,7 @@
# Model defaults for unsloth/Pixtral-12B-2409
# Based on Pixtral_(12B)-Vision.ipynb
# Also applies to: unsloth/Pixtral-12B-2409-unsloth-bnb-4bit, mistralai/Pixtral-12B-2409, unsloth/Pixtral-12B-2409-bnb-4bit
# added inference parameters from unsloth notebook
training:
max_seq_length: 2048
@ -39,3 +40,7 @@ logging:
tensorboard_dir: "runs"
log_frequency: 10
inference:
temperature: 1.5
min_p: 0.1

View file

@ -1,6 +1,7 @@
# Model defaults for OuteAI/Llama-OuteTTS-1.0-1B
# Based on Oute_TTS_(1B).ipynb
# Also applies to: OuteAI/Llama-OuteTTS-1.0-1B
# added inference parameters from unsloth notebook
training:
max_seq_length: 2048
@ -40,3 +41,9 @@ logging:
tensorboard_dir: "runs"
log_frequency: 10
inference:
temperature: 0.4
top_k: 40
top_p: 0.9
min_p: 0.05

View file

@ -1,6 +1,7 @@
# Model defaults for Spark-TTS-0.5B/LLM
# Based on Spark_TTS_(0_5B).ipynb
# Also applies to: Spark-TTS-0.5B/LLM
# added inference parameters from unsloth notebook
training:
max_seq_length: 2048
@ -45,3 +46,8 @@ logging:
tensorboard_dir: "runs"
log_frequency: 10
inference:
temperature: 0.8
top_k: 50
top_p: 1.0

View file

@ -1,6 +1,7 @@
# Model defaults for unsloth/LFM2-1.2B
# Based on Liquid_LFM2_(1.2B)-Conversational.ipynb
# Also applies to: unsloth/LFM2-1.2B
# added inference parameters from unsloth notebook
training:
max_seq_length: 2048
@ -39,3 +40,7 @@ logging:
tensorboard_dir: "runs"
log_frequency: 10
inference:
temperature: 0.3
min_p: 0.15

View file

@ -1,6 +1,7 @@
# Model defaults for unsloth/Nemotron-3-Nano-30B-A3B
# Based on Nemotron-3-Nano-30B-A3B_A100.ipynb
# Also applies to: unsloth/Nemotron-3-Nano-30B-A3B
# added inference parameters from unsloth guides
training:
max_seq_length: 2048
@ -47,3 +48,7 @@ logging:
tensorboard_dir: "runs"
log_frequency: 10
inference:
temperature: 1.0
top_p: 1.0

View file

@ -1,6 +1,7 @@
# Model defaults for unsloth/PaddleOCR-VL
# Based on Paddle_OCR_(1B)_Vision.ipynb
# Also applies to: unsloth/PaddleOCR-VL
# added inference parameters from unsloth notebook
training:
max_seq_length: 2048
@ -45,3 +46,7 @@ logging:
tensorboard_dir: "runs"
log_frequency: 10
inference:
temperature: 1.5
min_p: 0.1

View file

@ -1,6 +1,7 @@
# Model defaults for unsloth/orpheus-3b-0.1-ft
# Based on Orpheus_(3B)-TTS.ipynb
# Also applies to: unsloth/orpheus-3b-0.1-ft-unsloth-bnb-4bit, canopylabs/orpheus-3b-0.1-ft, unsloth/orpheus-3b-0.1-ft-bnb-4bit
# added inference parameters from unsloth notebook
training:
max_seq_length: 2048
@ -45,3 +46,7 @@ logging:
tensorboard_dir: "runs"
log_frequency: 10
inference:
temperature: 0.6
top_p: 0.95

View file

@ -1,6 +1,7 @@
# Model defaults for unsloth/Phi-4
# Based on Phi_4-Conversational.ipynb
# Also applies to: unsloth/phi-4-unsloth-bnb-4bit, microsoft/phi-4, unsloth/phi-4-bnb-4bit
# added inference parameters from unsloth guides
training:
max_seq_length: 2048
@ -45,3 +46,7 @@ logging:
tensorboard_dir: "runs"
log_frequency: 10
inference:
temperature: 0.8
top_p: 0.95

View file

@ -1,6 +1,7 @@
# Model defaults for unsloth/Qwen2-VL-7B-Instruct
# Based on Qwen2_VL_(7B)-Vision.ipynb
# Also applies to: unsloth/Qwen2-VL-7B-Instruct-unsloth-bnb-4bit, Qwen/Qwen2-VL-7B-Instruct, unsloth/Qwen2-VL-7B-Instruct-bnb-4bit
# added inference parameters from unsloth notebook
training:
max_seq_length: 2048
@ -39,3 +40,7 @@ logging:
tensorboard_dir: "runs"
log_frequency: 10
inference:
temperature: 1.5
min_p: 0.1

View file

@ -1,6 +1,7 @@
# Model defaults for unsloth/Qwen2.5-Coder-14B-Instruct
# Based on Qwen2.5_Coder_(14B)-Conversational.ipynb
# Also applies to: unsloth/Qwen2.5-Coder-14B-Instruct-bnb-4bit, Qwen/Qwen2.5-Coder-14B-Instruct
# added inference parameters from unsloth notebook
training:
max_seq_length: 2048
@ -45,3 +46,7 @@ logging:
tensorboard_dir: "runs"
log_frequency: 10
inference:
temperature: 1.5
min_p: 0.1

View file

@ -1,6 +1,7 @@
# Model defaults for unsloth/Qwen2.5-VL-7B-Instruct-bnb-4bit
# Based on Qwen2.5_VL_(7B)-Vision.ipynb
# Also applies to: unsloth/Qwen2.5-VL-7B-Instruct, Qwen/Qwen2.5-VL-7B-Instruct, unsloth/Qwen2.5-VL-7B-Instruct-unsloth-bnb-4bit
# added inference parameters from unsloth notebook
training:
max_seq_length: 2048
@ -39,3 +40,7 @@ logging:
tensorboard_dir: "runs"
log_frequency: 10
inference:
temperature: 1.5
min_p: 0.1

View file

@ -1,6 +1,7 @@
# Model defaults for unsloth/Qwen3-0.6B
# Based on Qwen3_(0_6B)-Phone_Deployment.ipynb
# Also applies to: unsloth/Qwen3-0.6B-unsloth-bnb-4bit, Qwen/Qwen3-0.6B, unsloth/Qwen3-0.6B-bnb-4bit, Qwen/Qwen3-0.6B-FP8, unsloth/Qwen3-0.6B-FP8
# added inference parameters from Ollama
training:
max_seq_length: 1024
@ -45,3 +46,8 @@ logging:
tensorboard_dir: "runs"
log_frequency: 10
inference:
temperature: 0.6
top_k: 20
top_p: 0.95

View file

@ -1,6 +1,7 @@
# Model defaults for unsloth/Qwen3-14B-Base
# Based on Qwen3_(14B)-Alpaca.ipynb
# Also applies to: unsloth/Qwen3-14B-Base, Qwen/Qwen3-14B-Base, unsloth/Qwen3-14B-Base-bnb-4bit
# added inference parameters from Ollama
training:
max_seq_length: 2048
@ -45,3 +46,8 @@ logging:
tensorboard_dir: "runs"
log_frequency: 10
inference:
temperature: 0.6
top_k: 20
top_p: 0.95

View file

@ -1,6 +1,7 @@
# Model defaults for unsloth/Qwen3-14B
# Based on Qwen3_(14B).ipynb
# Also applies to: unsloth/Qwen3-14B-unsloth-bnb-4bit, Qwen/Qwen3-14B, unsloth/Qwen3-14B-bnb-4bit, Qwen/Qwen3-14B-FP8, unsloth/Qwen3-14B-FP8
# added inference parameters from Ollama
training:
max_seq_length: 2048
@ -45,3 +46,8 @@ logging:
tensorboard_dir: "runs"
log_frequency: 10
inference:
temperature: 0.6
top_k: 20
top_p: 0.95

View file

@ -1,6 +1,7 @@
# Model defaults for unsloth/Qwen3-32B
# Based on Qwen3_(32B)_A100-Reasoning-Conversational.ipynb
# Also applies to: unsloth/Qwen3-32B-unsloth-bnb-4bit, Qwen/Qwen3-32B, unsloth/Qwen3-32B-bnb-4bit, Qwen/Qwen3-32B-FP8, unsloth/Qwen3-32B-FP8
# added inference parameters from Ollama
training:
max_seq_length: 2048
@ -45,3 +46,8 @@ logging:
tensorboard_dir: "runs"
log_frequency: 10
inference:
temperature: 0.6
top_k: 20
top_p: 0.95

View file

@ -1,6 +1,7 @@
# Model defaults for unsloth/Qwen3-4B-Instruct-2507
# Based on Qwen3_(4B)-Instruct.ipynb
# Also applies to: unsloth/Qwen3-4B-Instruct-2507-unsloth-bnb-4bit, Qwen/Qwen3-4B-Instruct-2507, unsloth/Qwen3-4B-Instruct-2507-bnb-4bit, Qwen/Qwen3-4B-Instruct-2507-FP8, unsloth/Qwen3-4B-Instruct-2507-FP8
# added inference parameters from unsloth guides
training:
max_seq_length: 2048
@ -45,3 +46,9 @@ logging:
tensorboard_dir: "runs"
log_frequency: 10
inference:
temperature: 0.7
top_p: 0.80
top_k: 20
min_p: 0.00

View file

@ -1,6 +1,7 @@
# Model defaults for unsloth/Qwen3-4B-Thinking-2507
# Based on Qwen3_(4B)-Thinking.ipynb
# Also applies to: unsloth/Qwen3-4B-Thinking-2507-unsloth-bnb-4bit, Qwen/Qwen3-4B-Thinking-2507, unsloth/Qwen3-4B-Thinking-2507-bnb-4bit, Qwen/Qwen3-4B-Thinking-2507-FP8, unsloth/Qwen3-4B-Thinking-2507-FP8
# added inference parameters from unsloth guides
training:
max_seq_length: 2048
@ -45,3 +46,9 @@ logging:
tensorboard_dir: "runs"
log_frequency: 10
inference:
temperature: 0.6
top_p: 0.95
top_k: 20
min_p: 0.00

View file

@ -1,6 +1,7 @@
# Model defaults for unsloth/Qwen3-VL-8B-Instruct
# Based on Qwen3_VL_(8B)-Vision.ipynb
# Also applies to: Qwen/Qwen3-VL-8B-Instruct-FP8, unsloth/Qwen3-VL-8B-Instruct-FP8, unsloth/Qwen3-VL-8B-Instruct, Qwen/Qwen3-VL-8B-Instruct, unsloth/Qwen3-VL-8B-Instruct-bnb-4bit
# added inference parameters from unsloth guides
training:
max_seq_length: 2048
@ -39,3 +40,8 @@ logging:
tensorboard_dir: "runs"
log_frequency: 10
inference:
temperature: 0.7
top_p: 0.8
top_k: 20

View file

@ -8,7 +8,7 @@ from peft import PeftModel, PeftModelForCausalLM
import sys
import torch
from typing import Optional, Generator, Tuple
from typing import Optional, Union, Generator, Tuple
from utils.models import ModelConfig, get_base_model_from_lora
from utils.paths import is_model_cached
from utils.utils import format_error_message
@ -179,24 +179,21 @@ class InferenceBackend:
model = self.models[base_model_name].get("model")
try:
# Step 1: Unload the adapter weights. This returns the base model object.
# This step is only necessary if the model is currently a PeftModel instance.
# Step 1: Unload the adapter weights if model is a PeftModel.
if isinstance(model, (PeftModel, PeftModelForCausalLM)):
logger.info("Model is a PeftModel. Unloading adapters...")
logger.info(f"Unloading LoRA adapters from '{base_model_name}'...")
unwrapped_base_model = model.unload()
self.models[base_model_name]["model"] = unwrapped_base_model
model = unwrapped_base_model # Continue with the unwrapped model
model = unwrapped_base_model
# Step 2: Delete any lingering adapter configurations from the object.
# This is the crucial step you identified.
if hasattr(model, 'peft_config') and model.peft_config:
logger.info("Found lingering adapter configurations. Deleting them now...")
# Create a static list of keys before iterating and deleting
for name in list(model.peft_config.keys()):
logger.info(f"Deleting adapter config: '{name}'")
model.delete_adapter(name)
# Step 2: Clear any lingering peft_config from the unwrapped model.
# After model.unload(), the base model may still carry a peft_config
# attribute. Removing it ensures PeftModel.from_pretrained() gets
# a clean base model without "multiple adapters" warnings.
if hasattr(model, 'peft_config'):
del model.peft_config
logger.info("Model has been successfully reverted to a clean base state.")
logger.info(f"Model '{base_model_name}' reverted to clean base state.")
return True
except Exception as e:
@ -204,35 +201,29 @@ class InferenceBackend:
import traceback
logger.error(traceback.format_exc())
return False
pass
def activate_lora_adapter(self, base_model_name: str, lora_path: str) -> Tuple[bool, Optional[str]]:
"""
Activates a specific LoRA adapter on what is assumed to be a clean base model.
Uses PeftModel.from_pretrained() which correctly wraps the base model.
"""
model = self.models[base_model_name].get("model")
adapter_name_to_load = lora_path.split("/")[-1].replace(".", "_")
try:
# At this point, the model should be clean thanks to revert_to_base_model.
# We can now safely load and set the new adapter.
# Step 3: Load the new adapter.
logger.info(f"Loading adapter '{adapter_name_to_load}' from '{lora_path}'")
model.load_adapter(lora_path, adapter_name=adapter_name_to_load)
# Step 4: Set the new adapter as active.
logger.info(f"Setting '{adapter_name_to_load}' as the active adapter.")
model.set_adapter(adapter_name_to_load)
# Use PeftModel.from_pretrained to wrap the clean base model with the adapter.
# This is the correct approach after model.unload() + del peft_config.
logger.info(f"Loading LoRA adapter '{adapter_name_to_load}' from '{lora_path}'...")
model = PeftModel.from_pretrained(model, lora_path, adapter_name=adapter_name_to_load)
self.models[base_model_name]["model"] = model
logger.info(f"LoRA adapter '{adapter_name_to_load}' activated successfully.")
return True, adapter_name_to_load
except Exception as e:
# This will catch the "already exists" error if revert_to_base_model failed.
logger.error(f"Failed to activate LoRA adapter '{adapter_name_to_load}': {e}")
import traceback
logger.error(traceback.format_exc())
return False, None
pass
def load_adapter(self, base_model_name: str, adapter_path: str, adapter_name: str = None) -> bool:
"""
@ -448,6 +439,74 @@ class InferenceBackend:
return False
pass
def _apply_adapter_state(self, use_adapter: Optional[Union[bool, str]]) -> None:
"""
Apply adapter state before generation. Must be called under _generation_lock.
Uses revert_to_base_model() / activate_lora_adapter() which work correctly
for models loaded by Unsloth as complete PeftModels (via model.unload() /
model.load_adapter()), matching the proven pattern from the Gradio eval page.
Args:
use_adapter: None = no change, False = disable (base model),
True = enable current adapter, str = enable specific adapter.
"""
if use_adapter is None:
return
base = self.active_model_name
if not base or base not in self.models:
return
model_info = self.models[base]
if use_adapter is False:
# Revert to pure base model by unloading adapter weights
logger.info(f"Compare mode: reverting '{base}' to base model for generation")
self.revert_to_base_model(base)
elif use_adapter is True:
# Activate the LoRA adapter from the original model path
lora_path = model_info.get("model_path")
if lora_path and model_info.get("is_lora"):
logger.info(f"Compare mode: activating LoRA adapter from '{lora_path}' on '{base}'")
self.activate_lora_adapter(base, lora_path)
else:
# Fallback for dynamically attached adapters
loaded = model_info.get("loaded_adapters", {})
if loaded:
adapter_name = list(loaded.keys())[-1]
logger.info(f"Compare mode: enabling adapter '{adapter_name}' on '{base}'")
self.set_active_adapter(base, adapter_name)
else:
logger.warning("use_adapter=true but no adapter path/adapters on model")
elif isinstance(use_adapter, str):
# Activate a specific adapter by path
logger.info(f"Compare mode: activating specific adapter '{use_adapter}' on '{base}'")
self.activate_lora_adapter(base, use_adapter)
def generate_with_adapter_control(
self,
use_adapter: Optional[Union[bool, str]] = None,
**gen_kwargs,
) -> Generator[str, None, None]:
"""
Thread-safe generation with optional adapter toggling.
Acquires the generation lock, applies adapter state, then generates.
This ensures adapter toggle + generation are atomic critical for
compare mode where base and LoRA panes fire concurrently.
Args:
use_adapter: Adapter control (None/False/True/str). See _apply_adapter_state.
**gen_kwargs: Forwarded to generate_chat_response.
"""
with self._generation_lock:
self._apply_adapter_state(use_adapter)
# Delegate to the lock-free generation path
yield from self._generate_chat_response_inner(**gen_kwargs)
def generate_chat_response(self,
messages: list,
system_prompt: str,
@ -459,11 +518,33 @@ class InferenceBackend:
repetition_penalty: float = 1.1) -> Generator[str, None, None]:
"""
Generate response for text or vision models.
Acquires the generation lock. For adapter-controlled generation,
use generate_with_adapter_control() instead.
"""
with self._generation_lock:
yield from self._generate_chat_response_inner(
messages=messages,
system_prompt=system_prompt,
image=image,
temperature=temperature,
top_p=top_p,
top_k=top_k,
max_new_tokens=max_new_tokens,
repetition_penalty=repetition_penalty,
)
1. Messages are already in ChatML format (role/content)
2. Apply get_chat_template() if model in mapper
3. Apply tokenizer.apply_chat_template()
4. Generate
def _generate_chat_response_inner(self,
messages: list,
system_prompt: str = "",
image=None,
temperature: float = 0.7,
top_p: float = 0.9,
top_k: int = 40,
max_new_tokens: int = 256,
repetition_penalty: float = 1.1) -> Generator[str, None, None]:
"""
Inner generation logic (no lock). Called by both generate_chat_response
and generate_with_adapter_control.
"""
if not self.active_model_name:
yield "Error: No active model"
@ -473,55 +554,54 @@ class InferenceBackend:
is_vision = model_info.get("is_vision", False)
tokenizer = model_info.get("tokenizer") or model_info.get("processor")
with self._generation_lock:
if is_vision:
# Vision model generation
yield from self._generate_vision_response(
messages, system_prompt, image,
temperature, top_p, top_k, max_new_tokens, repetition_penalty
)
else:
# Text model: Use training pipeline approach
# Messages are already in ChatML format from eval.py
if is_vision:
# Vision model generation
yield from self._generate_vision_response(
messages, system_prompt, image,
temperature, top_p, top_k, max_new_tokens, repetition_penalty
)
else:
# Text model: Use training pipeline approach
# Messages are already in ChatML format from eval.py
# Step 1: Apply get_chat_template if model is in mapper
try:
from utils.datasets import MODEL_TO_TEMPLATE_MAPPER, get_tokenizer_chat_template
# Step 1: Apply get_chat_template if model is in mapper
try:
from utils.datasets import MODEL_TO_TEMPLATE_MAPPER, get_tokenizer_chat_template
model_name_lower = self.active_model_name.lower()
model_name_lower = self.active_model_name.lower()
# Check if model has a registered template
if model_name_lower in MODEL_TO_TEMPLATE_MAPPER:
template_name = MODEL_TO_TEMPLATE_MAPPER[model_name_lower]
logger.info(f"Applying chat template '{template_name}' for {self.active_model_name}")
# Check if model has a registered template
if model_name_lower in MODEL_TO_TEMPLATE_MAPPER:
template_name = MODEL_TO_TEMPLATE_MAPPER[model_name_lower]
logger.info(f"Applying chat template '{template_name}' for {self.active_model_name}")
# This modifies the tokenizer with the correct template
tokenizer = get_chat_template(
tokenizer,
self.active_model_name
)
else:
logger.info(f"No registered template for {self.active_model_name}, using tokenizer default")
except Exception as e:
logger.warning(f"Could not apply get_chat_template: {e}")
# Step 2: Format with tokenizer.apply_chat_template()
try:
formatted_prompt = tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True
# This modifies the tokenizer with the correct template
tokenizer = get_chat_template(
tokenizer,
self.active_model_name
)
logger.debug(f"Formatted prompt: {formatted_prompt[:200]}...")
except Exception as e:
logger.error(f"Error applying chat template: {e}")
# Fallback to manual formatting
formatted_prompt = self.format_chat_prompt(messages, system_prompt)
else:
logger.info(f"No registered template for {self.active_model_name}, using tokenizer default")
except Exception as e:
logger.warning(f"Could not apply get_chat_template: {e}")
# Step 3: Generate
yield from self.generate_stream(
formatted_prompt, temperature, top_p, top_k, max_new_tokens, repetition_penalty
# Step 2: Format with tokenizer.apply_chat_template()
try:
formatted_prompt = tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True
)
logger.debug(f"Formatted prompt: {formatted_prompt[:200]}...")
except Exception as e:
logger.error(f"Error applying chat template: {e}")
# Fallback to manual formatting
formatted_prompt = self.format_chat_prompt(messages, system_prompt)
# Step 3: Generate
yield from self.generate_stream(
formatted_prompt, temperature, top_p, top_k, max_new_tokens, repetition_penalty
)
def _generate_vision_response(self, messages, system_prompt, image,
temperature, top_p, top_k, max_new_tokens,

View file

@ -2,13 +2,16 @@
Unsloth Training Backend
Integrates Unsloth training capabilities with the FastAPI backend
"""
import os
# Prevent tokenizer parallelism deadlocks when datasets uses multiprocessing fork
os.environ["TOKENIZERS_PARALLELISM"] = "false"
import torch
from utils.hardware import clear_gpu_cache
torch._dynamo.config.recompile_limit = 64
from unsloth import FastLanguageModel, FastVisionModel, is_bfloat16_supported
from unsloth.chat_templates import get_chat_template
import os
import json
import threading
import math
@ -58,6 +61,7 @@ class UnslothTrainer:
self.progress_callbacks = []
self.is_training = False
self.should_stop = False
self.save_on_stop = True
# Model state tracking
self.is_vlm = False
@ -558,6 +562,12 @@ class UnslothTrainer:
config_args["warmup_steps"] = 5
print(f"Using default warmup_steps: 5\n")
# Add save_steps if specified
save_steps_val = training_args.get('save_steps', 0)
if save_steps_val and save_steps_val > 0:
config_args["save_steps"] = save_steps_val
config_args["save_strategy"] = "steps"
# If max_steps is specified, use it instead of epochs
max_steps_val = training_args.get('max_steps', 0)
if max_steps_val and max_steps_val > 0:
@ -756,16 +766,32 @@ class UnslothTrainer:
self.trainer.train()
# ========== SAVE MODEL ==========
self.trainer.save_model()
self.tokenizer.save_pretrained(output_dir)
print(f"\nTraining completed! Model saved to {output_dir}\n")
self._update_progress(
is_training=False,
is_completed=True,
#status_message=status_msg
status_message=f"Training completed! Model saved to {output_dir}",
)
if self.should_stop and self.save_on_stop:
# Stopped by user — save model at current checkpoint
self.trainer.save_model()
self.tokenizer.save_pretrained(output_dir)
print(f"\nTraining stopped. Model saved to {output_dir}\n")
self._update_progress(
is_training=False,
status_message=f"Training stopped. Model saved to {output_dir}",
)
elif self.should_stop:
# Cancelled by user — don't save
print("\nTraining cancelled.\n")
self._update_progress(
is_training=False,
status_message="Training cancelled.",
)
else:
# Normal completion
self.trainer.save_model()
self.tokenizer.save_pretrained(output_dir)
print(f"\nTraining completed! Model saved to {output_dir}\n")
self._update_progress(
is_training=False,
is_completed=True,
status_message=f"Training completed! Model saved to {output_dir}",
)
except Exception as e:
logger.error(f"Training error: {e}")
@ -774,10 +800,11 @@ class UnslothTrainer:
finally:
self.is_training = False
def stop_training(self):
def stop_training(self, save: bool = True):
"""Stop ongoing training"""
print("\nStopping training...")
print(f"\nStopping training (save={save})...")
self.should_stop = True
self.save_on_stop = save
self.is_training = False
# Clear the status message so timer doesn't show stale status
self._update_progress(is_training=False, status_message="")

View file

@ -6,6 +6,7 @@ from typing import Any, Generator, Tuple
import logging
from .trainer import get_trainer, TrainingProgress
from utils.hardware import clear_gpu_cache
logger = logging.getLogger(__name__)
@ -101,8 +102,34 @@ class TrainingBackend:
True if training started successfully, False otherwise.
"""
try:
# Wait for any previous training thread to finish
old_thread = getattr(self.trainer, "training_thread", None)
if old_thread and old_thread.is_alive():
logger.info("Waiting for previous training thread to finish...")
old_thread.join(timeout=30)
# Explicitly free old SFTTrainer and CUDA resources before loading new model.
# Without this, forked multiprocessing workers (num_proc tokenization) inherit
# stale CUDA state from the previous run, causing extreme slowdowns or crashes.
if self.trainer.trainer is not None:
logger.info("Cleaning up previous SFTTrainer...")
self.trainer.trainer = None
if self.trainer.model is not None:
self.trainer.model = None
if self.trainer.tokenizer is not None:
self.trainer.tokenizer = None
# Flush all pending async CUDA ops so forked tokenization processes
# don't inherit stale async state that causes pool join to hang.
import torch as _torch
if _torch.cuda.is_available():
_torch.cuda.synchronize()
import gc
gc.collect()
clear_gpu_cache()
# Reset stop flag and clear history
self.trainer.should_stop = False
self.trainer.save_on_stop = True
self.loss_history = []
self.lr_history = []
self.step_history = []
@ -224,16 +251,19 @@ class TrainingBackend:
)
return False
def stop_training(self) -> bool:
def stop_training(self, save: bool = True) -> bool:
"""
Stop ongoing training.
Args:
save: If True, save the model at the current checkpoint.
Returns:
True if training was successfully stopped.
"""
try:
logger.info("Stopping training...")
self.trainer.stop_training()
logger.info(f"Stopping training (save={save})...")
self.trainer.stop_training(save=save)
return True
except Exception as e:
logger.error(f"Error stopping training: {e}")
@ -293,6 +323,10 @@ class TrainingBackend:
True if training is in progress, False otherwise
"""
try:
# If user requested stop, training is no longer considered active
if self.trainer.should_stop:
return False
progress = self.trainer.get_training_progress()
# Training is active if is_training is True
# Also check if we're in loading/preparation phase (status_message indicates activity)

View file

@ -14,12 +14,13 @@ from datetime import datetime
# Import routers
from routes import (
training_router,
models_router,
inference_router,
datasets_router,
auth_router,
data_recipe_router,
datasets_router,
export_router,
inference_router,
models_router,
training_router,
)
from auth import storage
from utils.hardware import detect_hardware
@ -75,6 +76,7 @@ app.include_router(models_router, prefix="/api/models", tags=["models"])
app.include_router(inference_router, prefix="/api/inference", tags=["inference"])
app.include_router(datasets_router, prefix="/api/datasets", tags=["datasets"])
app.include_router(data_recipe_router, prefix="/api/data-recipe", tags=["data-recipe"])
app.include_router(export_router, prefix="/api/export", tags=["export"])
# ============ Health and System Endpoints ============
@ -136,7 +138,7 @@ def setup_frontend(app: FastAPI, build_path: Path):
@app.get("/")
async def serve_root():
return FileResponse(build_path / "index.html")
return FileResponse(build_path / "index.html", headers={"Cache-Control": "no-cache, no-store, must-revalidate"})
@app.get("/{full_path:path}")
async def serve_frontend(full_path: str):
@ -147,7 +149,7 @@ def setup_frontend(app: FastAPI, build_path: Path):
if file_path.is_file():
return FileResponse(file_path)
return FileResponse(build_path / "index.html")
return FileResponse(build_path / "index.html", headers={"Cache-Control": "no-cache, no-store, must-revalidate"})
return True
return False

View file

@ -19,6 +19,17 @@ from .auth import (
RefreshTokenRequest,
AuthStatusResponse,
)
from .export import (
CheckpointInfo,
CheckpointListResponse,
LoadCheckpointRequest,
ExportStatusResponse,
ExportOperationResponse,
ExportMergedModelRequest,
ExportBaseModelRequest,
ExportGGUFRequest,
ExportLoRAAdapterRequest,
)
from .users import Token
from .datasets import (
CheckFormatRequest,
@ -62,6 +73,16 @@ __all__ = [
"AuthLoginRequest",
"RefreshTokenRequest",
"AuthStatusResponse",
# Export schemas
"CheckpointInfo",
"CheckpointListResponse",
"LoadCheckpointRequest",
"ExportStatusResponse",
"ExportOperationResponse",
"ExportMergedModelRequest",
"ExportBaseModelRequest",
"ExportGGUFRequest",
"ExportLoRAAdapterRequest",
"Token",
# Dataset schemas
"CheckFormatRequest",

View file

@ -0,0 +1,141 @@
"""
Pydantic schemas for Export API.
"""
from pydantic import BaseModel, Field
from typing import List, Optional, Literal, Dict, Any
class CheckpointInfo(BaseModel):
"""Information about a discovered checkpoint directory."""
display_name: str = Field(..., description="User-friendly checkpoint name (folder name)")
path: str = Field(..., description="Full path to the checkpoint directory")
class CheckpointListResponse(BaseModel):
"""Response for listing available checkpoints in an outputs directory."""
outputs_dir: str = Field(..., description="Directory that was scanned")
checkpoints: List[CheckpointInfo] = Field(
default_factory=list,
description="List of discovered checkpoints",
)
class LoadCheckpointRequest(BaseModel):
"""Request for loading a checkpoint into the export backend."""
checkpoint_path: str = Field(..., description="Path to the checkpoint directory")
max_seq_length: int = Field(
2048,
ge=128,
le=32768,
description="Maximum sequence length for loading the model",
)
load_in_4bit: bool = Field(
True,
description="Whether to load the model in 4-bit quantization",
)
class ExportStatusResponse(BaseModel):
"""Current export backend status."""
current_checkpoint: Optional[str] = Field(
None,
description="Path to the currently loaded checkpoint, if any",
)
is_vision: bool = Field(
False,
description="True if the loaded checkpoint is a vision model",
)
is_peft: bool = Field(
False,
description="True if the loaded checkpoint is a PEFT (LoRA) model",
)
class ExportOperationResponse(BaseModel):
"""Generic response for export operations."""
success: bool = Field(..., description="True if the operation succeeded")
message: str = Field(..., description="Human-readable status or error message")
details: Optional[Dict[str, Any]] = Field(
default=None,
description="Optional extra details about the operation",
)
class ExportCommonOptions(BaseModel):
"""Common options for export operations that save locally and/or push to Hub."""
save_directory: str = Field(
...,
description="Local directory where the exported artifacts will be written",
)
push_to_hub: bool = Field(
False,
description="If True, also push the exported model to the Hugging Face Hub",
)
repo_id: Optional[str] = Field(
None,
description="Hugging Face Hub repository ID (username/model-name)",
)
hf_token: Optional[str] = Field(
None,
description="Hugging Face access token used for Hub operations",
)
private: bool = Field(
False,
description="If True, create a private repository on the Hub (where applicable)",
)
class ExportMergedModelRequest(ExportCommonOptions):
"""Request for exporting a merged PEFT model."""
format_type: Literal["16-bit (FP16)", "4-bit (FP4)"] = Field(
"16-bit (FP16)",
description="Export precision / format for the merged model",
)
class ExportBaseModelRequest(ExportCommonOptions):
"""Request for exporting a non-PEFT (base) model."""
# Uses fields from ExportCommonOptions only
pass
class ExportGGUFRequest(BaseModel):
"""Request for exporting the current model to GGUF format."""
save_directory: str = Field(
...,
description="Directory where GGUF files will be saved",
)
quantization_method: str = Field(
"Q4_K_M",
description='GGUF quantization method (e.g. "Q4_K_M")',
)
push_to_hub: bool = Field(
False,
description="If True, also push GGUF artifacts to the Hugging Face Hub",
)
repo_id: Optional[str] = Field(
None,
description="Hugging Face Hub repository ID for GGUF upload",
)
hf_token: Optional[str] = Field(
None,
description="Hugging Face token for GGUF upload",
)
class ExportLoRAAdapterRequest(ExportCommonOptions):
"""Request for exporting only the LoRA adapter (not merged)."""
# Uses fields from ExportCommonOptions only
pass

View file

@ -43,6 +43,7 @@ class LoadResponse(BaseModel):
display_name: str = Field(..., description="Display name of the model")
is_vision: bool = Field(False, description="Whether model is a vision model")
is_lora: bool = Field(False, description="Whether model is a LoRA adapter")
inference: dict = Field(..., description="Inference parameters (temperature, top_p, top_k, min_p)")
class UnloadResponse(BaseModel):
@ -130,6 +131,16 @@ class ChatCompletionRequest(BaseModel):
top_k: int = Field(40, ge=1, le=100, description="[x-unsloth] Top-k sampling")
repetition_penalty: float = Field(1.1, ge=1.0, le=2.0, description="[x-unsloth] Repetition penalty")
image_base64: Optional[str] = Field(None, description="[x-unsloth] Base64-encoded image for vision models")
use_adapter: Optional[Union[bool, str]] = Field(
None,
description=(
"[x-unsloth] Adapter control for compare mode. "
"null = no change (default), "
"false = disable adapters (base model), "
"true = enable the current adapter, "
"string = enable a specific adapter by name."
),
)
# ── Streaming response chunks ────────────────────────────────────

View file

@ -8,6 +8,7 @@ from routes.inference import router as inference_router
from routes.datasets import router as datasets_router
from routes.auth import router as auth_router
from routes.data_recipe import router as data_recipe_router
from routes.export import router as export_router
__all__ = [
"training_router",
@ -16,4 +17,5 @@ __all__ = [
"datasets_router",
"auth_router",
"data_recipe_router",
"export_router",
]

View file

@ -0,0 +1,310 @@
"""
Export API routes: checkpoint discovery and model export operations.
"""
import sys
from pathlib import Path
from fastapi import APIRouter, Depends, HTTPException, Query
import logging
# Add backend directory to path
backend_path = Path(__file__).parent.parent.parent
if str(backend_path) not in sys.path:
sys.path.insert(0, str(backend_path))
# Auth
from auth.authentication import get_current_subject
# Import backend functions
try:
from core.export import get_export_backend
except ImportError:
parent_backend = backend_path.parent / "backend"
if str(parent_backend) not in sys.path:
sys.path.insert(0, str(parent_backend))
from core.export import get_export_backend
# Import Pydantic models
from models import (
CheckpointInfo,
CheckpointListResponse,
LoadCheckpointRequest,
ExportStatusResponse,
ExportOperationResponse,
ExportMergedModelRequest,
ExportBaseModelRequest,
ExportGGUFRequest,
ExportLoRAAdapterRequest,
)
router = APIRouter()
logger = logging.getLogger(__name__)
# Configure logger
if not logger.handlers:
handler = logging.StreamHandler()
handler.setLevel(logging.INFO)
formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')
handler.setFormatter(formatter)
logger.addHandler(handler)
logger.setLevel(logging.INFO)
@router.get("/checkpoints", response_model=CheckpointListResponse)
async def list_checkpoints(
outputs_dir: str = Query(
default="./outputs",
description="Directory to scan for checkpoints",
),
current_subject: str = Depends(get_current_subject),
):
"""
List available checkpoints in the outputs directory.
Wraps ExportBackend.scan_checkpoints.
"""
try:
backend = get_export_backend()
raw_checkpoints = backend.scan_checkpoints(outputs_dir=outputs_dir)
checkpoints = [
CheckpointInfo(display_name=display_name, path=path)
for display_name, path in raw_checkpoints
]
return CheckpointListResponse(
outputs_dir=outputs_dir,
checkpoints=checkpoints,
)
except Exception as e:
logger.error(f"Error listing checkpoints: {e}", exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Failed to list checkpoints: {str(e)}",
)
@router.post("/load-checkpoint", response_model=ExportOperationResponse)
async def load_checkpoint(
request: LoadCheckpointRequest,
current_subject: str = Depends(get_current_subject),
):
"""
Load a checkpoint into the export backend.
Wraps ExportBackend.load_checkpoint.
"""
try:
backend = get_export_backend()
success, message = backend.load_checkpoint(
checkpoint_path=request.checkpoint_path,
max_seq_length=request.max_seq_length,
load_in_4bit=request.load_in_4bit,
)
if not success:
raise HTTPException(status_code=400, detail=message)
return ExportOperationResponse(success=True, message=message)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error loading checkpoint: {e}", exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Failed to load checkpoint: {str(e)}",
)
@router.post("/cleanup", response_model=ExportOperationResponse)
async def cleanup_export_memory(
current_subject: str = Depends(get_current_subject),
):
"""
Cleanup export-related models from memory (GPU/CPU).
Wraps ExportBackend.cleanup_memory.
"""
try:
backend = get_export_backend()
success = backend.cleanup_memory()
if not success:
raise HTTPException(
status_code=500,
detail="Memory cleanup failed. See server logs for details.",
)
return ExportOperationResponse(
success=True,
message="Memory cleanup completed successfully",
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error during export memory cleanup: {e}", exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Failed to cleanup export memory: {str(e)}",
)
@router.get("/status", response_model=ExportStatusResponse)
async def get_export_status(
current_subject: str = Depends(get_current_subject),
):
"""
Get current export backend status (loaded checkpoint, model type, PEFT flag).
"""
try:
backend = get_export_backend()
return ExportStatusResponse(
current_checkpoint=backend.current_checkpoint,
is_vision=bool(getattr(backend, "is_vision", False)),
is_peft=bool(getattr(backend, "is_peft", False)),
)
except Exception as e:
logger.error(f"Error getting export status: {e}", exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Failed to get export status: {str(e)}",
)
@router.post("/export/merged", response_model=ExportOperationResponse)
async def export_merged_model(
request: ExportMergedModelRequest,
current_subject: str = Depends(get_current_subject),
):
"""
Export a merged PEFT model (e.g., 16-bit or 4-bit) and optionally push to Hub.
Wraps ExportBackend.export_merged_model.
"""
try:
backend = get_export_backend()
success, message = backend.export_merged_model(
save_directory=request.save_directory,
format_type=request.format_type,
push_to_hub=request.push_to_hub,
repo_id=request.repo_id,
hf_token=request.hf_token,
private=request.private,
)
if not success:
raise HTTPException(status_code=400, detail=message)
return ExportOperationResponse(success=True, message=message)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error exporting merged model: {e}", exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Failed to export merged model: {str(e)}",
)
@router.post("/export/base", response_model=ExportOperationResponse)
async def export_base_model(
request: ExportBaseModelRequest,
current_subject: str = Depends(get_current_subject),
):
"""
Export a non-PEFT base model and optionally push to Hub.
Wraps ExportBackend.export_base_model.
"""
try:
backend = get_export_backend()
success, message = backend.export_base_model(
save_directory=request.save_directory,
push_to_hub=request.push_to_hub,
repo_id=request.repo_id,
hf_token=request.hf_token,
private=request.private,
)
if not success:
raise HTTPException(status_code=400, detail=message)
return ExportOperationResponse(success=True, message=message)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error exporting base model: {e}", exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Failed to export base model: {str(e)}",
)
@router.post("/export/gguf", response_model=ExportOperationResponse)
async def export_gguf(
request: ExportGGUFRequest,
current_subject: str = Depends(get_current_subject),
):
"""
Export the current model to GGUF format and optionally push to Hub.
Wraps ExportBackend.export_gguf.
"""
try:
backend = get_export_backend()
success, message = backend.export_gguf(
save_directory=request.save_directory,
quantization_method=request.quantization_method,
push_to_hub=request.push_to_hub,
repo_id=request.repo_id,
hf_token=request.hf_token,
)
if not success:
raise HTTPException(status_code=400, detail=message)
return ExportOperationResponse(success=True, message=message)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error exporting GGUF model: {e}", exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Failed to export GGUF model: {str(e)}",
)
@router.post("/export/lora", response_model=ExportOperationResponse)
async def export_lora_adapter(
request: ExportLoRAAdapterRequest,
current_subject: str = Depends(get_current_subject),
):
"""
Export only the LoRA adapter (if the loaded model is PEFT).
Wraps ExportBackend.export_lora_adapter.
"""
try:
backend = get_export_backend()
success, message = backend.export_lora_adapter(
save_directory=request.save_directory,
push_to_hub=request.push_to_hub,
repo_id=request.repo_id,
hf_token=request.hf_token,
private=request.private,
)
if not success:
raise HTTPException(status_code=400, detail=message)
return ExportOperationResponse(success=True, message=message)
except HTTPException:
raise
except Exception as e:
logger.error(f"Error exporting LoRA adapter: {e}", exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Failed to export LoRA adapter: {str(e)}",
)

View file

@ -22,12 +22,14 @@ if str(backend_path) not in sys.path:
try:
from core.inference import get_inference_backend
from utils.models import ModelConfig
from utils.inference import load_inference_config
except ImportError:
parent_backend = backend_path.parent / "backend"
if str(parent_backend) not in sys.path:
sys.path.insert(0, str(parent_backend))
from core.inference import get_inference_backend
from utils.models import ModelConfig
from utils.inference import load_inference_config
from models.inference import (
LoadRequest,
@ -64,15 +66,17 @@ async def load_model(request: LoadRequest):
Load a model for inference.
The model_path should be a clean identifier from GET /models/list.
Returns inference configuration parameters (temperature, top_p, top_k, min_p)
from the model's YAML config, falling back to default.yaml for missing values.
"""
try:
backend = get_inference_backend()
# Create config using clean factory method
# is_lora is auto-detected from adapter_config.json on disk/HF
config = ModelConfig.from_identifier(
model_id=request.model_path,
hf_token=request.hf_token,
is_lora=request.is_lora,
)
if not config:
@ -97,12 +101,16 @@ async def load_model(request: LoadRequest):
logger.info(f"Loaded model: {config.identifier}")
# Load inference configuration parameters
inference_config = load_inference_config(config.identifier)
return LoadResponse(
status="loaded",
model=config.identifier,
display_name=config.display_name,
is_vision=config.is_vision,
is_lora=config.is_lora,
inference=inference_config,
)
except HTTPException:
@ -365,6 +373,18 @@ async def openai_chat_completions(request: ChatCompletionRequest):
repetition_penalty=request.repetition_penalty,
)
# ── Choose generation path (adapter-controlled or standard) ──
if request.use_adapter is not None:
# Compare mode: toggle adapter state atomically with generation
def generate():
return backend.generate_with_adapter_control(
use_adapter=request.use_adapter, **gen_kwargs
)
else:
# Standard path: no adapter toggling
def generate():
return backend.generate_chat_response(**gen_kwargs)
model_name = backend.active_model_name or request.model
completion_id = f"chatcmpl-{uuid.uuid4().hex[:12]}"
created = int(time.time())
@ -388,7 +408,7 @@ async def openai_chat_completions(request: ChatCompletionRequest):
# Content chunks — generate_chat_response yields cumulative
# text, so we diff to get incremental deltas.
prev_text = ""
for cumulative in backend.generate_chat_response(**gen_kwargs):
for cumulative in generate():
new_text = cumulative[len(prev_text):]
prev_text = cumulative
if not new_text:
@ -439,7 +459,7 @@ async def openai_chat_completions(request: ChatCompletionRequest):
else:
try:
full_text = ""
for token in backend.generate_chat_response(**gen_kwargs):
for token in generate():
full_text = token # generate_stream yields cumulative text
response = ChatCompletion(

View file

@ -37,6 +37,11 @@ from models import (
TrainingProgress,
)
from models.responses import TrainingStopResponse, TrainingMetricsResponse
from pydantic import BaseModel as PydanticBaseModel
class TrainingStopRequest(PydanticBaseModel):
save: bool = True
router = APIRouter()
logger = logging.getLogger(__name__)
@ -251,10 +256,14 @@ async def start_training(
@router.post("/stop", response_model=TrainingStopResponse)
async def stop_training(
body: TrainingStopRequest = TrainingStopRequest(),
current_subject: str = Depends(get_current_subject),
):
"""
Stop the currently running training job.
Body:
save (bool): If True (default), save the model at the current checkpoint.
"""
try:
backend = get_training_backend()
@ -266,7 +275,7 @@ async def stop_training(
)
# Call backend stop method
backend.stop_training()
backend.stop_training(save=body.save)
return TrainingStopResponse(
status="stopped",
@ -281,6 +290,29 @@ async def stop_training(
)
@router.post("/reset")
async def reset_training(
current_subject: str = Depends(get_current_subject),
):
"""
Reset training state so the user can return to configuration.
"""
try:
backend = get_training_backend()
backend.trainer.should_stop = False
backend.trainer.training_progress = backend.trainer.training_progress.__class__()
backend.loss_history = []
backend.lr_history = []
backend.step_history = []
return {"status": "ok"}
except Exception as e:
logger.error(f"Error resetting training: {e}", exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Failed to reset training: {str(e)}",
)
@router.get("/status")
async def get_training_status(
current_subject: str = Depends(get_current_subject),
@ -313,6 +345,9 @@ async def get_training_status(
) or "Ready to train"
error_message = getattr(progress, "error", None) if progress else None
# Check if training was stopped by user
trainer_stopped = getattr(backend.trainer, "should_stop", False)
# Derive high-level phase
if error_message:
phase = "error"
@ -326,6 +361,8 @@ async def get_training_status(
phase = "configuring"
else:
phase = "training"
elif trainer_stopped:
phase = "stopped"
elif progress and getattr(progress, "is_completed", False):
phase = "completed"
elif has_thread:

View file

@ -0,0 +1,7 @@
"""
Inference utility functions
"""
from utils.inference.inference_config import load_inference_config
__all__ = ["load_inference_config"]

View file

@ -0,0 +1,65 @@
"""
Inference configuration loading utilities.
This module provides functions to load inference parameters (temperature, top_p, top_k, min_p)
from model YAML configuration files, with fallback to default.yaml.
"""
from pathlib import Path
from typing import Dict, Any
import yaml
import logging
from utils.models.model_config import load_model_defaults
logger = logging.getLogger(__name__)
def load_inference_config(model_identifier: str) -> Dict[str, Any]:
"""
Load inference configuration parameters for a model.
This function loads inference parameters (temperature, top_p, top_k, min_p) from the
model's YAML configuration file using the same mapping logic as the /config endpoint.
If a parameter is missing from the model's config, it falls back to the value in
default.yaml.
Args:
model_identifier: Model identifier (e.g., "unsloth/llama-3-8b-bnb-4bit")
Returns:
Dictionary containing inference parameters:
{
"temperature": float,
"top_p": float,
"top_k": int,
"min_p": float
}
"""
# Load model defaults to get inference parameters
model_defaults = load_model_defaults(model_identifier)
# Load default.yaml for fallback values
script_dir = Path(__file__).parent.parent.parent
defaults_dir = script_dir / "assets" / "configs" / "model_defaults"
default_config_path = defaults_dir / "default.yaml"
default_inference = {}
if default_config_path.exists():
try:
with open(default_config_path, 'r', encoding='utf-8') as f:
default_config = yaml.safe_load(f) or {}
default_inference = default_config.get("inference", {})
except Exception as e:
logger.warning(f"Failed to load default.yaml: {e}")
# Extract inference parameters from model config, fallback to defaults
model_inference = model_defaults.get("inference", {})
inference_config = {
"temperature": model_inference.get("temperature", default_inference.get("temperature", 0.7)),
"top_p": model_inference.get("top_p", default_inference.get("top_p", 0.95)),
"top_k": model_inference.get("top_k", default_inference.get("top_k", -1)),
"min_p": model_inference.get("min_p", default_inference.get("min_p", 0.01)),
}
return inference_config

View file

@ -12,9 +12,21 @@ async function hasActiveSession(): Promise<boolean> {
return refreshSession();
}
async function checkAuthInitialized(): Promise<boolean> {
try {
const res = await fetch("/api/auth/status");
if (!res.ok) return true; // fallback to login on error
const data = (await res.json()) as { initialized: boolean };
return data.initialized;
} catch {
return true; // fallback to login on error
}
}
export async function requireAuth(): Promise<void> {
if (await hasActiveSession()) return;
throw redirect({ to: "/login" });
const initialized = await checkAuthInitialized();
throw redirect({ to: initialized ? "/login" : "/signup" });
}
export async function requireGuest(): Promise<void> {

View file

@ -10,6 +10,26 @@ type RefreshResponse = {
refresh_token: string;
};
let isRedirecting = false;
async function redirectToAuth(): Promise<void> {
if (isRedirecting) return;
isRedirecting = true;
let target = "/login";
try {
const res = await fetch("/api/auth/status");
if (res.ok) {
const data = (await res.json()) as { initialized: boolean };
if (!data.initialized) target = "/signup";
}
} catch {
// Fall through to /login on error
}
window.location.href = target;
}
export async function refreshSession(): Promise<boolean> {
const refreshToken = getRefreshToken();
if (!refreshToken) return false;
@ -48,7 +68,11 @@ export async function authFetch(
if (response.status !== 401) return response;
const refreshed = await refreshSession();
if (!refreshed) return response;
if (!refreshed) {
clearAuthTokens();
void redirectToAuth();
return response;
}
const retryHeaders = new Headers(init?.headers);
const newToken = getAuthToken();

View file

@ -63,7 +63,18 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null {
const response = await fetch("/api/auth/status");
if (!response.ok) throw new Error("Failed to load auth status.");
const result = (await response.json()) as AuthStatusResponse;
if (!canceled) setInitialized(result.initialized);
if (!canceled) {
setInitialized(result.initialized);
// Auto-redirect to the correct page based on init state
if (mode === "login" && result.initialized === false) {
navigate({ to: "/signup" });
return;
}
if (mode === "signup" && result.initialized === true) {
navigate({ to: "/login" });
return;
}
}
} catch (err: unknown) {
if (!canceled) {
setError(err instanceof Error ? err.message : "Failed to load.");
@ -199,6 +210,9 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null {
)}
</Button>
</div>
{!isLoginMode && (
<p className="text-xs text-muted-foreground">Must be at least 8 characters</p>
)}
</div>
{!isLoginMode && (
@ -223,7 +237,7 @@ export function AuthForm({ mode }: AuthFormProps): ReactElement | null {
<Button
type="submit"
className="w-full"
disabled={loading || statusLoading || blockedByState}
disabled={loading || statusLoading || blockedByState || (!isLoginMode && password.length < 8)}
>
{loading ? "Please wait..." : submitLabel}
</Button>

View file

@ -223,7 +223,7 @@ export function DatasetSection() {
<div className="flex flex-col gap-2">
<span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
Dataset Format
Target Format
<Tooltip>
<TooltipTrigger asChild={true}>
<button
@ -258,7 +258,7 @@ export function DatasetSection() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="auto">Auto-detect</SelectItem>
<SelectItem value="auto">Auto</SelectItem>
<SelectItem value="alpaca">Alpaca</SelectItem>
<SelectItem value="chatml">ChatML</SelectItem>
<SelectItem value="sharegpt">ShareGPT</SelectItem>

View file

@ -1,4 +1,14 @@
import { SectionCard } from "@/components/section-card";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
Popover,
@ -104,6 +114,7 @@ export function ProgressSection(): ReactElement {
);
const { stopTrainingRun } = useTrainingActions();
const [stopDialogOpen, setStopDialogOpen] = useState(false);
const localStartAtRef = useRef<number | null>(null);
const [, setLocalTick] = useState(0);
@ -225,15 +236,39 @@ export function ProgressSection(): ReactElement {
</div>
</PopoverContent>
</Popover>
<Button
variant="destructive"
size="sm"
className="h-7 cursor-pointer px-3 text-xs"
onClick={() => void stopTrainingRun()}
disabled={!runtime.isTrainingRunning}
>
<HugeiconsIcon icon={StopIcon} className="size-3" /> Stop
</Button>
<AlertDialog open={stopDialogOpen} onOpenChange={setStopDialogOpen}>
<Button
variant="destructive"
size="sm"
className="h-7 cursor-pointer px-3 text-xs"
onClick={() => setStopDialogOpen(true)}
disabled={!runtime.isTrainingRunning}
>
<HugeiconsIcon icon={StopIcon} className="size-3" /> Stop
</Button>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Stop Training</AlertDialogTitle>
<AlertDialogDescription>
Choose how you want to stop the current training run.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Continue Training</AlertDialogCancel>
<AlertDialogAction
variant="destructive"
onClick={() => void stopTrainingRun(false)}
>
Cancel Training
</AlertDialogAction>
<AlertDialogAction
onClick={() => void stopTrainingRun(true)}
>
Stop and Save
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
}
>

View file

@ -1,8 +1,12 @@
import { Button } from "@/components/ui/button";
import {
shouldShowTrainingView,
useTrainingActions,
useTrainingRuntimeLifecycle,
useTrainingRuntimeStore,
} from "@/features/training";
import { ArrowLeft01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import type { ReactElement } from "react";
import { DatasetSection } from "./sections/dataset-section";
import { ModelSection } from "./sections/model-section";
@ -14,12 +18,28 @@ export function StudioPage(): ReactElement {
useTrainingRuntimeLifecycle();
const showTrainingView = useTrainingRuntimeStore(shouldShowTrainingView);
const runtimeMessage = useTrainingRuntimeStore((state) => state.message);
const runtimePhase = useTrainingRuntimeStore((state) => state.phase);
const isHydratingRuntime = useTrainingRuntimeStore((state) => state.isHydrating);
const hasHydratedRuntime = useTrainingRuntimeStore((state) => state.hasHydrated);
const { dismissTrainingRun } = useTrainingActions();
const canGoBack = runtimePhase === "stopped" || runtimePhase === "error";
return (
<div className="min-h-screen bg-background">
<main className="mx-auto max-w-7xl px-6 py-4">
{canGoBack && (
<Button
variant="ghost"
size="sm"
className="mb-2 cursor-pointer gap-1.5 text-muted-foreground"
onClick={() => void dismissTrainingRun()}
>
<HugeiconsIcon icon={ArrowLeft01Icon} className="size-4" />
Back to configuration
</Button>
)}
{/* Header */}
<div className="mb-8 flex flex-col gap-0.5">
<h1 className="text-2xl font-semibold tracking-tight">

View file

@ -41,11 +41,22 @@ export async function startTraining(
return parseJson<TrainingStartResponse>(response);
}
export async function stopTraining(): Promise<TrainingStopResponse> {
const response = await authFetch("/api/train/stop", { method: "POST" });
export async function stopTraining(save = true): Promise<TrainingStopResponse> {
const response = await authFetch("/api/train/stop", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ save }),
});
return parseJson<TrainingStopResponse>(response);
}
export async function resetTraining(): Promise<void> {
const response = await authFetch("/api/train/reset", { method: "POST" });
if (!response.ok) {
throw new Error(await readError(response));
}
}
export async function getTrainingStatus(): Promise<TrainingStatusResponse> {
const response = await authFetch("/api/train/status");
return parseJson<TrainingStatusResponse>(response);

View file

@ -1,7 +1,7 @@
import { useCallback } from "react";
import { useTrainingConfigStore } from "../stores/training-config-store";
import { useTrainingRuntimeStore } from "../stores/training-runtime-store";
import { startTraining, stopTraining } from "../api/train-api";
import { startTraining, stopTraining, resetTraining } from "../api/train-api";
import { buildTrainingStartPayload } from "../api/mappers";
import { syncTrainingRuntimeFromBackend } from "../lib/sync-runtime";
import { validateTrainingConfig } from "../lib/validation";
@ -45,12 +45,12 @@ export function useTrainingActions() {
}
}, []);
const stopTrainingRun = useCallback(async (): Promise<boolean> => {
const stopTrainingRun = useCallback(async (save = true): Promise<boolean> => {
const runtimeStore = useTrainingRuntimeStore.getState();
runtimeStore.setStartError(null);
try {
await stopTraining();
await stopTraining(save);
await syncTrainingRuntimeFromBackend();
return true;
} catch (error) {
@ -61,10 +61,20 @@ export function useTrainingActions() {
}
}, []);
const dismissTrainingRun = useCallback(async (): Promise<void> => {
useTrainingRuntimeStore.getState().resetRuntime();
try {
await resetTraining();
} catch {
// Frontend already reset; backend will catch up on next poll
}
}, []);
return {
isStarting,
startError,
startTrainingRun,
stopTrainingRun,
dismissTrainingRun,
};
}

View file

@ -1,3 +1,4 @@
import { hasAuthToken } from "@/features/auth";
import { useEffect } from "react";
import {
getTrainingMetrics,
@ -48,23 +49,27 @@ export function useTrainingRuntimeLifecycle(): void {
};
const pollMetrics = async () => {
if (!hasAuthToken()) return;
const gen = runtimeStore.getState().resetGeneration;
try {
const metrics = await getTrainingMetrics();
if (disposed) {
if (disposed || runtimeStore.getState().resetGeneration !== gen) {
return;
}
runtimeStore.getState().applyMetrics(metrics);
} catch (error) {
if (!isAbortError(error) && !disposed) {
if (!isAbortError(error) && !disposed && hasAuthToken()) {
runtimeStore.getState().setSseConnected(false);
}
}
};
const pollStatus = async () => {
if (!hasAuthToken()) return;
const gen = runtimeStore.getState().resetGeneration;
try {
const status = await getTrainingStatus();
if (disposed) {
if (disposed || runtimeStore.getState().resetGeneration !== gen) {
return;
}
@ -77,7 +82,7 @@ export function useTrainingRuntimeLifecycle(): void {
stopStream();
}
} catch (error) {
if (!isAbortError(error) && !disposed) {
if (!isAbortError(error) && !disposed && hasAuthToken()) {
runtimeStore.getState().setSseConnected(false);
}
}

View file

@ -6,12 +6,17 @@ import { useTrainingRuntimeStore } from "../stores/training-runtime-store";
import type { TrainingStatusResponse } from "../types/runtime";
export async function syncTrainingRuntimeFromBackend(): Promise<TrainingStatusResponse> {
const gen = useTrainingRuntimeStore.getState().resetGeneration;
const [status, metrics] = await Promise.all([
getTrainingStatus(),
getTrainingMetrics(),
]);
const runtimeStore = useTrainingRuntimeStore.getState();
if (runtimeStore.resetGeneration !== gen) {
return status;
}
runtimeStore.applyStatus(status);
runtimeStore.applyMetrics(metrics);

View file

@ -34,6 +34,7 @@ const initialState: TrainingRuntimeState = {
lossHistory: [],
lrHistory: [],
gradNormHistory: [],
resetGeneration: 0,
};
function sortSeries(points: TrainingSeriesPoint[]): TrainingSeriesPoint[] {
@ -95,12 +96,13 @@ export const useTrainingRuntimeStore = create<TrainingRuntimeStore>()((set) => (
setLastEventId: (value) => set({ lastEventId: value }),
resetRuntime: () =>
set({
set((state) => ({
...initialState,
lossHistory: [],
lrHistory: [],
gradNormHistory: [],
}),
resetGeneration: state.resetGeneration + 1,
})),
setStartQueued: (jobId, message) =>
set({

View file

@ -82,6 +82,7 @@ export interface TrainingRuntimeState {
lossHistory: TrainingSeriesPoint[];
lrHistory: TrainingSeriesPoint[];
gradNormHistory: TrainingSeriesPoint[];
resetGeneration: number;
}
export interface TrainingRuntimeActions {