Final cleanup
This commit is contained in:
parent
a2baf80511
commit
47654cb91c
123 changed files with 7474 additions and 5805 deletions
240
README.md
240
README.md
|
|
@ -1,240 +0,0 @@
|
|||
<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
|
||||
|
||||
### Prerequisites
|
||||
|
||||
| Requirement | Linux / WSL | Windows |
|
||||
|---|---|---|
|
||||
| **GPU** | NVIDIA GPU with working driver | NVIDIA GPU with working driver |
|
||||
| **Python** | 3.11 – 3.13 | 3.11 – 3.13 |
|
||||
| **Git** | Pre-installed on most distros | Auto-installed by setup script (via `winget`) |
|
||||
| **CMake** | Pre-installed or `sudo apt install cmake` | Auto-installed by setup script (via `winget`) |
|
||||
| **C++ compiler** | `build-essential` (auto-detected) | Visual Studio Build Tools 2022 (auto-installed by setup script) |
|
||||
| **CUDA Toolkit** | Optional — setup auto-detects `nvcc` | Auto-installed by setup script (version matched to driver) |
|
||||
|
||||
> [!NOTE]
|
||||
> On **WSL**, the setup script will also run `sudo apt-get install build-essential cmake curl git libcurl4-openssl-dev` so that GGUF export works in non-interactive subprocesses. You may be prompted for your password during setup.
|
||||
|
||||
---
|
||||
|
||||
### Linux / Windows WSL
|
||||
|
||||
```bash
|
||||
# 1. Clone the repo
|
||||
git clone https://github.com/unslothai/unsloth-studio.git
|
||||
cd unsloth-studio
|
||||
|
||||
# 2. Run setup (installs Node, builds frontend, creates .venv, builds llama.cpp)
|
||||
bash setup.sh
|
||||
|
||||
# 3. Open a new terminal (or source your shell rc), then launch:
|
||||
unsloth-studio -H 0.0.0.0 -p 8000
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b>What does <code>setup.sh</code> do?</b></summary>
|
||||
|
||||
1. Installs **Node.js ≥ 20** via nvm (if needed)
|
||||
2. Runs `npm install && npm run build` for the React frontend
|
||||
3. Detects the best **Python 3.11 – 3.13** on your system and creates a `.venv`
|
||||
4. Installs all Python dependencies (unsloth, PyTorch with CUDA, triton kernels, etc.)
|
||||
5. On **WSL**: pre-installs build dependencies via `apt-get`
|
||||
6. Clones and builds **llama.cpp** at `~/.unsloth/llama.cpp` (GPU-accelerated if CUDA is found)
|
||||
7. Registers `unsloth-studio` and `unsloth-ui` shell aliases in your shell rc (bash, zsh, fish, or ksh)
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### Windows (Native)
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Requires an **NVIDIA GPU** — CPU-only machines are not supported on Windows.
|
||||
|
||||
```powershell
|
||||
# 1. Clone the repo
|
||||
git clone https://github.com/unslothai/unsloth-studio.git
|
||||
cd unsloth-studio
|
||||
|
||||
# 2. Run setup (Right-click → "Run with PowerShell", or from a terminal):
|
||||
.\setup.bat
|
||||
# Or directly:
|
||||
powershell -ExecutionPolicy Bypass -File setup.ps1
|
||||
```
|
||||
|
||||
After setup completes, **open a new terminal** and run:
|
||||
|
||||
```powershell
|
||||
# PowerShell
|
||||
unsloth-studio -H 0.0.0.0 -p 8000
|
||||
|
||||
# Or cmd.exe
|
||||
unsloth-studio -H 0.0.0.0 -p 8000
|
||||
```
|
||||
|
||||
<details>
|
||||
<summary><b>What does <code>setup.ps1</code> do?</b></summary>
|
||||
|
||||
1. Enables **Windows Long Paths** (required for deep dependency trees — prompts for UAC)
|
||||
2. Auto-installs missing system tools via `winget`: **Git**, **CMake**, **Visual Studio Build Tools 2022**, **CUDA Toolkit** (version-matched to your driver), **Node.js LTS**, **Python 3.12**, **OpenSSL dev**
|
||||
3. Builds the React frontend (`npm install && npm run build`)
|
||||
4. Creates a `.venv` and installs all Python dependencies (including CUDA-enabled PyTorch from the official index)
|
||||
5. Sets `TORCHINDUCTOR_CACHE_DIR=C:\tc` to avoid Windows MAX_PATH issues with Triton
|
||||
6. Clones and builds **llama.cpp** at `%USERPROFILE%\.unsloth\llama.cpp` with CUDA + Visual Studio
|
||||
7. Registers `unsloth-studio` and `unsloth-ui` commands in both PowerShell profile and `cmd.exe` (via batch files on PATH)
|
||||
|
||||
</details>
|
||||
|
||||
---
|
||||
|
||||
### Google Colab
|
||||
|
||||
The setup script auto-detects Colab and installs everything into the existing system Python (no venv):
|
||||
|
||||
```python
|
||||
!bash setup.sh
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Launching the Studio
|
||||
|
||||
After setup on any platform, the command is the same:
|
||||
|
||||
```bash
|
||||
unsloth-studio -H 0.0.0.0 -p 8000
|
||||
```
|
||||
|
||||
| Flag | Description |
|
||||
|---|---|
|
||||
| `-H` / `--host` | Bind address (`0.0.0.0` for all interfaces, `127.0.0.1` for local only) |
|
||||
| `-p` / `--port` | Port number (default: `8000`) |
|
||||
|
||||
On **first launch**, a one-time setup token is printed to the console. Open the URL shown in your browser and use this token to create your admin account.
|
||||
|
||||
> [!TIP]
|
||||
> This repo is in active development. After pulling new changes, **always re-run the setup script** (`bash setup.sh` or `.\setup.bat`) to pick up dependency and build updates.
|
||||
|
||||
## 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 # Bootstrap script (Linux / WSL / Colab)
|
||||
├── setup.ps1 # Bootstrap script (Windows native)
|
||||
├── setup.bat # Wrapper to launch setup.ps1 via double-click
|
||||
├── install_python_stack.py # Cross-platform Python dependency installer
|
||||
└── 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.
|
||||
|
|
@ -10,8 +10,8 @@ from cli.commands.ui import ui
|
|||
from cli.commands.studio import studio_app
|
||||
|
||||
app = typer.Typer(
|
||||
help="Command-line interface for Unsloth training, inference, and export.",
|
||||
context_settings={"help_option_names": ["-h", "--help"]},
|
||||
help = "Command-line interface for Unsloth training, inference, and export.",
|
||||
context_settings = {"help_option_names": ["-h", "--help"]},
|
||||
)
|
||||
|
||||
app.command()(train)
|
||||
|
|
@ -19,4 +19,4 @@ app.command()(inference)
|
|||
app.command()(export)
|
||||
app.command("list-checkpoints")(list_checkpoints)
|
||||
app.command()(ui)
|
||||
app.add_typer(studio_app, name="studio", help="Unsloth Studio commands.")
|
||||
app.add_typer(studio_app, name = "studio", help = "Unsloth Studio commands.")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
|
@ -13,14 +13,14 @@ GGUF_QUANTS = ["q4_k_m", "q5_k_m", "q8_0", "f16"]
|
|||
|
||||
def list_checkpoints(
|
||||
outputs_dir: Path = typer.Option(
|
||||
Path("./outputs"), "--outputs-dir", help="Directory that holds training runs."
|
||||
Path("./outputs"), "--outputs-dir", help = "Directory that holds training runs."
|
||||
),
|
||||
):
|
||||
"""List checkpoints detected in the outputs directory."""
|
||||
from studio.backend.core.export import ExportBackend
|
||||
|
||||
backend = ExportBackend()
|
||||
checkpoints = backend.scan_checkpoints(outputs_dir=str(outputs_dir))
|
||||
checkpoints = backend.scan_checkpoints(outputs_dir = str(outputs_dir))
|
||||
if not checkpoints:
|
||||
typer.echo("No checkpoints found.")
|
||||
raise typer.Exit()
|
||||
|
|
@ -33,96 +33,100 @@ def list_checkpoints(
|
|||
|
||||
|
||||
def export(
|
||||
checkpoint: Path = typer.Argument(..., help="Path to checkpoint directory."),
|
||||
output_dir: Path = typer.Argument(..., help="Directory to save exported model."),
|
||||
checkpoint: Path = typer.Argument(..., help = "Path to checkpoint directory."),
|
||||
output_dir: Path = typer.Argument(..., help = "Directory to save exported model."),
|
||||
format: str = typer.Option(
|
||||
"merged-16bit",
|
||||
"--format",
|
||||
"-f",
|
||||
help=f"Export format: {', '.join(EXPORT_FORMATS)}",
|
||||
help = f"Export format: {', '.join(EXPORT_FORMATS)}",
|
||||
),
|
||||
quantization: str = typer.Option(
|
||||
"q4_k_m",
|
||||
"--quantization",
|
||||
"-q",
|
||||
help=f"GGUF quantization method: {', '.join(GGUF_QUANTS)}",
|
||||
help = f"GGUF quantization method: {', '.join(GGUF_QUANTS)}",
|
||||
),
|
||||
push_to_hub: bool = typer.Option(
|
||||
False, "--push-to-hub", help="Push exported model to HuggingFace Hub."
|
||||
False, "--push-to-hub", help = "Push exported model to HuggingFace Hub."
|
||||
),
|
||||
repo_id: Optional[str] = typer.Option(
|
||||
None, "--repo-id", help="HuggingFace repo ID (username/model-name)."
|
||||
None, "--repo-id", help = "HuggingFace repo ID (username/model-name)."
|
||||
),
|
||||
hf_token: Optional[str] = typer.Option(
|
||||
None, "--hf-token", envvar="HF_TOKEN", help="HuggingFace token."
|
||||
None, "--hf-token", envvar = "HF_TOKEN", help = "HuggingFace token."
|
||||
),
|
||||
private: bool = typer.Option(
|
||||
False, "--private", help="Make the HuggingFace repo private."
|
||||
False, "--private", help = "Make the HuggingFace repo private."
|
||||
),
|
||||
max_seq_length: int = typer.Option(2048, "--max-seq-length"),
|
||||
load_in_4bit: bool = typer.Option(True, "--load-in-4bit/--no-load-in-4bit"),
|
||||
):
|
||||
"""Export a checkpoint to various formats (merged, GGUF, LoRA adapter)."""
|
||||
if format not in EXPORT_FORMATS:
|
||||
typer.echo(f"Error: Invalid format '{format}'. Choose from: {', '.join(EXPORT_FORMATS)}", err=True)
|
||||
raise typer.Exit(code=2)
|
||||
typer.echo(
|
||||
f"Error: Invalid format '{format}'. Choose from: {', '.join(EXPORT_FORMATS)}",
|
||||
err = True,
|
||||
)
|
||||
raise typer.Exit(code = 2)
|
||||
|
||||
if push_to_hub and not repo_id:
|
||||
typer.echo("Error: --repo-id required when using --push-to-hub", err=True)
|
||||
raise typer.Exit(code=2)
|
||||
typer.echo("Error: --repo-id required when using --push-to-hub", err = True)
|
||||
raise typer.Exit(code = 2)
|
||||
|
||||
from studio.backend.core.export import ExportBackend
|
||||
|
||||
backend = ExportBackend()
|
||||
|
||||
typer.echo(f"Loading checkpoint: {checkpoint}")
|
||||
success, message = backend.load_checkpoint(
|
||||
checkpoint_path=str(checkpoint),
|
||||
max_seq_length=max_seq_length,
|
||||
load_in_4bit=load_in_4bit,
|
||||
checkpoint_path = str(checkpoint),
|
||||
max_seq_length = max_seq_length,
|
||||
load_in_4bit = load_in_4bit,
|
||||
)
|
||||
if not success:
|
||||
typer.echo(f"Error: {message}", err=True)
|
||||
raise typer.Exit(code=1)
|
||||
typer.echo(f"Error: {message}", err = True)
|
||||
raise typer.Exit(code = 1)
|
||||
typer.echo(message)
|
||||
|
||||
typer.echo(f"Exporting as {format}...")
|
||||
if format == "merged-16bit":
|
||||
success, message = backend.export_merged_model(
|
||||
save_directory=str(output_dir),
|
||||
format_type="16-bit (FP16)",
|
||||
push_to_hub=push_to_hub,
|
||||
repo_id=repo_id,
|
||||
hf_token=hf_token,
|
||||
private=private,
|
||||
save_directory = str(output_dir),
|
||||
format_type = "16-bit (FP16)",
|
||||
push_to_hub = push_to_hub,
|
||||
repo_id = repo_id,
|
||||
hf_token = hf_token,
|
||||
private = private,
|
||||
)
|
||||
elif format == "merged-4bit":
|
||||
success, message = backend.export_merged_model(
|
||||
save_directory=str(output_dir),
|
||||
format_type="4-bit (FP4)",
|
||||
push_to_hub=push_to_hub,
|
||||
repo_id=repo_id,
|
||||
hf_token=hf_token,
|
||||
private=private,
|
||||
save_directory = str(output_dir),
|
||||
format_type = "4-bit (FP4)",
|
||||
push_to_hub = push_to_hub,
|
||||
repo_id = repo_id,
|
||||
hf_token = hf_token,
|
||||
private = private,
|
||||
)
|
||||
elif format == "gguf":
|
||||
success, message = backend.export_gguf(
|
||||
save_directory=str(output_dir),
|
||||
quantization_method=quantization.upper(),
|
||||
push_to_hub=push_to_hub,
|
||||
repo_id=repo_id,
|
||||
hf_token=hf_token,
|
||||
save_directory = str(output_dir),
|
||||
quantization_method = quantization.upper(),
|
||||
push_to_hub = push_to_hub,
|
||||
repo_id = repo_id,
|
||||
hf_token = hf_token,
|
||||
)
|
||||
elif format == "lora":
|
||||
success, message = backend.export_lora_adapter(
|
||||
save_directory=str(output_dir),
|
||||
push_to_hub=push_to_hub,
|
||||
repo_id=repo_id,
|
||||
hf_token=hf_token,
|
||||
private=private,
|
||||
save_directory = str(output_dir),
|
||||
push_to_hub = push_to_hub,
|
||||
repo_id = repo_id,
|
||||
hf_token = hf_token,
|
||||
private = private,
|
||||
)
|
||||
|
||||
if not success:
|
||||
typer.echo(f"Error: {message}", err=True)
|
||||
raise typer.Exit(code=1)
|
||||
typer.echo(f"Error: {message}", err = True)
|
||||
raise typer.Exit(code = 1)
|
||||
|
||||
typer.echo(message)
|
||||
|
|
|
|||
|
|
@ -8,10 +8,10 @@ import typer
|
|||
|
||||
|
||||
def inference(
|
||||
model: str = typer.Argument(..., help="HF model id or local path."),
|
||||
prompt: str = typer.Argument(..., help="Prompt to send to the model."),
|
||||
model: str = typer.Argument(..., help = "HF model id or local path."),
|
||||
prompt: str = typer.Argument(..., help = "Prompt to send to the model."),
|
||||
hf_token: Optional[str] = typer.Option(
|
||||
None, "--hf-token", envvar="HF_TOKEN", help="Hugging Face token if needed."
|
||||
None, "--hf-token", envvar = "HF_TOKEN", help = "Hugging Face token if needed."
|
||||
),
|
||||
temperature: float = typer.Option(0.7, "--temperature"),
|
||||
top_p: float = typer.Option(0.9, "--top-p"),
|
||||
|
|
@ -21,7 +21,7 @@ def inference(
|
|||
system_prompt: str = typer.Option(
|
||||
"",
|
||||
"--system-prompt",
|
||||
help="Optional system prompt to prepend.",
|
||||
help = "Optional system prompt to prepend.",
|
||||
),
|
||||
max_seq_length: int = typer.Option(2048, "--max-seq-length"),
|
||||
load_in_4bit: bool = typer.Option(True, "--load-in-4bit/--no-load-in-4bit"),
|
||||
|
|
@ -31,36 +31,36 @@ def inference(
|
|||
|
||||
inference_backend = get_inference_backend()
|
||||
model_config = ModelConfig.from_ui_selection(
|
||||
dropdown_value=model, search_value=None, hf_token=hf_token, is_lora=False
|
||||
dropdown_value = model, search_value = None, hf_token = hf_token, is_lora = False
|
||||
)
|
||||
if not model_config:
|
||||
typer.echo("Could not resolve model config", err=True)
|
||||
raise typer.Exit(code=1)
|
||||
typer.echo("Could not resolve model config", err = True)
|
||||
raise typer.Exit(code = 1)
|
||||
|
||||
if not inference_backend.load_model(
|
||||
config=model_config,
|
||||
max_seq_length=max_seq_length,
|
||||
load_in_4bit=load_in_4bit,
|
||||
hf_token=hf_token,
|
||||
config = model_config,
|
||||
max_seq_length = max_seq_length,
|
||||
load_in_4bit = load_in_4bit,
|
||||
hf_token = hf_token,
|
||||
):
|
||||
typer.echo("Model load failed", err=True)
|
||||
raise typer.Exit(code=1)
|
||||
typer.echo("Model load failed", err = True)
|
||||
raise typer.Exit(code = 1)
|
||||
|
||||
messages = [{"role": "user", "content": prompt}]
|
||||
stream = inference_backend.generate_chat_response(
|
||||
messages=messages,
|
||||
system_prompt=system_prompt,
|
||||
temperature=temperature,
|
||||
top_p=top_p,
|
||||
top_k=top_k,
|
||||
max_new_tokens=max_new_tokens,
|
||||
repetition_penalty=repetition_penalty,
|
||||
messages = messages,
|
||||
system_prompt = system_prompt,
|
||||
temperature = temperature,
|
||||
top_p = top_p,
|
||||
top_k = top_k,
|
||||
max_new_tokens = max_new_tokens,
|
||||
repetition_penalty = repetition_penalty,
|
||||
)
|
||||
|
||||
typer.echo("Assistant:", nl=True)
|
||||
typer.echo("Assistant:", nl = True)
|
||||
previous = ""
|
||||
for chunk in stream:
|
||||
delta = chunk[len(previous):]
|
||||
delta = chunk[len(previous) :]
|
||||
if delta:
|
||||
sys.stdout.write(delta)
|
||||
sys.stdout.flush()
|
||||
|
|
|
|||
|
|
@ -10,28 +10,44 @@ from pathlib import Path
|
|||
from typing import Optional
|
||||
import typer
|
||||
|
||||
studio_app = typer.Typer(help="Unsloth Studio commands.")
|
||||
studio_app = typer.Typer(help = "Unsloth Studio commands.")
|
||||
|
||||
STUDIO_HOME = Path.home() / ".unsloth" / "studio"
|
||||
|
||||
# __file__ is cli/commands/studio.py — two parents up is the package root
|
||||
# (either site-packages or the repo root for editable installs).
|
||||
_PACKAGE_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
|
||||
|
||||
def _is_repo_root(path: Path) -> bool:
|
||||
"""Check if a directory looks like the repo root (actual git clone, not site-packages)."""
|
||||
return (
|
||||
(path / ".git").exists()
|
||||
and (path / "pyproject.toml").is_file()
|
||||
and (
|
||||
(path / "studio" / "setup.sh").is_file()
|
||||
or (path / "studio" / "setup.ps1").is_file()
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _get_repo_root() -> Optional[Path]:
|
||||
"""Find the git clone repo root, or None if pure pip install."""
|
||||
"""Find the git clone repo root.
|
||||
|
||||
Used only by setup() — checks __file__ first (editable install),
|
||||
then walks CWD parents (wheel install, user is inside the clone).
|
||||
"""
|
||||
# Check 1: __file__ is in the repo (editable install)
|
||||
candidate = Path(__file__).resolve().parent.parent.parent
|
||||
if (candidate / "pyproject.toml").is_file() and (candidate / "studio" / "setup.sh").is_file():
|
||||
return candidate
|
||||
# Check 2: CWD is the repo (non-editable wheel, running from repo dir)
|
||||
cwd = Path.cwd()
|
||||
if (cwd / "pyproject.toml").is_file() and (cwd / "studio" / "setup.sh").is_file():
|
||||
return cwd
|
||||
if _is_repo_root(_PACKAGE_ROOT):
|
||||
return _PACKAGE_ROOT
|
||||
# Check 2: CWD or any parent is the repo
|
||||
cwd = Path.cwd().resolve()
|
||||
for parent in (cwd, *cwd.parents):
|
||||
if _is_repo_root(parent):
|
||||
return parent
|
||||
return None
|
||||
|
||||
|
||||
def _is_git_clone() -> bool:
|
||||
return _get_repo_root() is not None
|
||||
|
||||
|
||||
def _studio_venv_python() -> Optional[Path]:
|
||||
"""Return the studio venv Python binary, or None if not set up."""
|
||||
if platform.system() == "Windows":
|
||||
|
|
@ -42,37 +58,69 @@ def _studio_venv_python() -> Optional[Path]:
|
|||
|
||||
|
||||
def _find_run_py() -> Optional[Path]:
|
||||
"""Find studio/backend/run.py."""
|
||||
# 1. Repo root (git clone / editable)
|
||||
repo = _get_repo_root()
|
||||
if repo:
|
||||
run_py = repo / "studio" / "backend" / "run.py"
|
||||
if run_py.is_file():
|
||||
return run_py
|
||||
# 2. Studio venv's site-packages
|
||||
for match in (STUDIO_HOME / ".venv").glob("lib/python*/site-packages/studio/backend/run.py"):
|
||||
return match
|
||||
# 3. Current package's site-packages
|
||||
run_py = Path(__file__).resolve().parent.parent.parent / "studio" / "backend" / "run.py"
|
||||
return run_py if run_py.is_file() else None
|
||||
"""Find studio/backend/run.py.
|
||||
|
||||
No CWD dependency — works from any directory.
|
||||
Since studio/ is now a proper package (has __init__.py), it lives in
|
||||
site-packages after pip install, right next to cli/.
|
||||
"""
|
||||
# 1. Relative to __file__ (site-packages or editable repo root)
|
||||
run_py = _PACKAGE_ROOT / "studio" / "backend" / "run.py"
|
||||
if run_py.is_file():
|
||||
return run_py
|
||||
# 2. Studio venv's site-packages (Linux + Windows layouts)
|
||||
for pattern in (
|
||||
"lib/python*/site-packages/studio/backend/run.py",
|
||||
"Lib/site-packages/studio/backend/run.py",
|
||||
):
|
||||
for match in (STUDIO_HOME / ".venv").glob(pattern):
|
||||
return match
|
||||
return None
|
||||
|
||||
|
||||
def _find_install_script() -> Optional[Path]:
|
||||
"""Find studio/install_python_stack.py."""
|
||||
# 1. Repo root
|
||||
repo = _get_repo_root()
|
||||
if repo:
|
||||
s = repo / "studio" / "install_python_stack.py"
|
||||
if s.is_file():
|
||||
return s
|
||||
# 2. Relative to __file__ (in site-packages)
|
||||
s = Path(__file__).resolve().parent.parent.parent / "studio" / "install_python_stack.py"
|
||||
return s if s.is_file() else None
|
||||
"""Find studio/install_python_stack.py.
|
||||
|
||||
No CWD dependency — works from any directory.
|
||||
"""
|
||||
# 1. Relative to __file__ (site-packages or editable repo root)
|
||||
s = _PACKAGE_ROOT / "studio" / "install_python_stack.py"
|
||||
if s.is_file():
|
||||
return s
|
||||
# 2. Studio venv's site-packages
|
||||
for pattern in (
|
||||
"lib/python*/site-packages/studio/install_python_stack.py",
|
||||
"Lib/site-packages/studio/install_python_stack.py",
|
||||
):
|
||||
for match in (STUDIO_HOME / ".venv").glob(pattern):
|
||||
return match
|
||||
return None
|
||||
|
||||
|
||||
def _find_setup_script() -> Optional[Path]:
|
||||
"""Find studio/setup.sh or studio/setup.ps1.
|
||||
|
||||
No CWD dependency — works from any directory.
|
||||
"""
|
||||
name = "setup.ps1" if platform.system() == "Windows" else "setup.sh"
|
||||
# 1. Relative to __file__ (site-packages or editable repo root)
|
||||
s = _PACKAGE_ROOT / "studio" / name
|
||||
if s.is_file():
|
||||
return s
|
||||
# 2. Studio venv's site-packages
|
||||
for pattern in (
|
||||
f"lib/python*/site-packages/studio/{name}",
|
||||
f"Lib/site-packages/studio/{name}",
|
||||
):
|
||||
for match in (STUDIO_HOME / ".venv").glob(pattern):
|
||||
return match
|
||||
return None
|
||||
|
||||
|
||||
# ── unsloth studio (server) ──────────────────────────────────────────
|
||||
|
||||
@studio_app.callback(invoke_without_command=True)
|
||||
|
||||
@studio_app.callback(invoke_without_command = True)
|
||||
def studio_default(
|
||||
ctx: typer.Context,
|
||||
port: int = typer.Option(8000, "--port", "-p"),
|
||||
|
|
@ -94,7 +142,14 @@ def studio_default(
|
|||
if studio_python and run_py:
|
||||
if not silent:
|
||||
typer.echo("Launching with studio venv...")
|
||||
args = [str(studio_python), str(run_py), "--host", host, "--port", str(port)]
|
||||
args = [
|
||||
str(studio_python),
|
||||
str(run_py),
|
||||
"--host",
|
||||
host,
|
||||
"--port",
|
||||
str(port),
|
||||
]
|
||||
if frontend:
|
||||
args.extend(["--frontend", str(frontend)])
|
||||
if silent:
|
||||
|
|
@ -108,14 +163,15 @@ def studio_default(
|
|||
|
||||
if not silent:
|
||||
from studio.backend.run import _resolve_external_ip
|
||||
|
||||
display_host = _resolve_external_ip() if host == "0.0.0.0" else host
|
||||
typer.echo(f"Starting Unsloth Studio on http://{display_host}:{port}")
|
||||
|
||||
run_server(
|
||||
host=host,
|
||||
port=port,
|
||||
frontend_path=frontend,
|
||||
silent=silent,
|
||||
host = host,
|
||||
port = port,
|
||||
frontend_path = frontend,
|
||||
silent = silent,
|
||||
)
|
||||
|
||||
try:
|
||||
|
|
@ -127,25 +183,30 @@ def studio_default(
|
|||
|
||||
# ── unsloth studio setup ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@studio_app.command()
|
||||
def setup():
|
||||
"""Run one-time Studio environment setup."""
|
||||
if _is_git_clone():
|
||||
_dev_setup()
|
||||
# If we're inside a git clone, use the full setup script (builds frontend, etc.)
|
||||
repo = _get_repo_root()
|
||||
if repo:
|
||||
_dev_setup(repo)
|
||||
else:
|
||||
_pip_setup()
|
||||
|
||||
|
||||
def _dev_setup():
|
||||
def _dev_setup(repo_root: Path):
|
||||
"""Git-clone: run setup.sh / setup.ps1."""
|
||||
repo_root = _get_repo_root()
|
||||
studio_dir = repo_root / "studio"
|
||||
if platform.system() == "Windows":
|
||||
script = studio_dir / "setup.ps1"
|
||||
subprocess.run(["powershell", "-ExecutionPolicy", "Bypass", "-File", str(script)], check=True)
|
||||
subprocess.run(
|
||||
["powershell", "-ExecutionPolicy", "Bypass", "-File", str(script)],
|
||||
check = True,
|
||||
)
|
||||
else:
|
||||
script = studio_dir / "setup.sh"
|
||||
subprocess.run(["bash", str(script)], check=True)
|
||||
subprocess.run(["bash", str(script)], check = True)
|
||||
|
||||
|
||||
def _pip_setup():
|
||||
|
|
@ -167,14 +228,14 @@ def _pip_setup():
|
|||
# 1. Create venv
|
||||
if not venv_python.is_file():
|
||||
typer.echo(f" Creating venv at {venv_dir}...")
|
||||
STUDIO_HOME.mkdir(parents=True, exist_ok=True)
|
||||
_venv.create(str(venv_dir), with_pip=True)
|
||||
STUDIO_HOME.mkdir(parents = True, exist_ok = True)
|
||||
_venv.create(str(venv_dir), with_pip = True)
|
||||
|
||||
# 2. Install all Python deps via install_python_stack.py
|
||||
install_script = _find_install_script()
|
||||
if install_script:
|
||||
typer.echo(" Installing Python dependencies...")
|
||||
subprocess.run([str(venv_python), str(install_script)], check=True)
|
||||
subprocess.run([str(venv_python), str(install_script)], check = True)
|
||||
else:
|
||||
typer.echo("Error: Could not find install_python_stack.py")
|
||||
raise typer.Exit(1)
|
||||
|
|
@ -184,16 +245,28 @@ def _pip_setup():
|
|||
typer.echo(f" Transformers 5.x overlay already at {venv_t5_dir}")
|
||||
else:
|
||||
typer.echo(" Installing transformers 5.x overlay...")
|
||||
venv_t5_dir.mkdir(parents=True, exist_ok=True)
|
||||
venv_t5_dir.mkdir(parents = True, exist_ok = True)
|
||||
subprocess.run(
|
||||
[str(venv_pip), "install",
|
||||
"--target", str(venv_t5_dir), "--no-deps", "transformers==5.2.0"],
|
||||
check=True,
|
||||
[
|
||||
str(venv_pip),
|
||||
"install",
|
||||
"--target",
|
||||
str(venv_t5_dir),
|
||||
"--no-deps",
|
||||
"transformers==5.2.0",
|
||||
],
|
||||
check = True,
|
||||
)
|
||||
subprocess.run(
|
||||
[str(venv_pip), "install",
|
||||
"--target", str(venv_t5_dir), "--no-deps", "huggingface_hub==1.3.0"],
|
||||
check=True,
|
||||
[
|
||||
str(venv_pip),
|
||||
"install",
|
||||
"--target",
|
||||
str(venv_t5_dir),
|
||||
"--no-deps",
|
||||
"huggingface_hub==1.3.0",
|
||||
],
|
||||
check = True,
|
||||
)
|
||||
typer.echo(f" Installed to {venv_t5_dir}")
|
||||
|
||||
|
|
@ -221,12 +294,26 @@ def _build_llama_cpp():
|
|||
typer.echo(" Building llama.cpp for GGUF inference...")
|
||||
|
||||
if llama_dir.exists():
|
||||
shutil.rmtree(llama_dir)
|
||||
unsloth_home.mkdir(parents=True, exist_ok=True)
|
||||
# necessary because shutil.rmtree fails on Windows because .git pack files are read-only
|
||||
def _force_remove_readonly(func, path, exc_info):
|
||||
"""Clear read-only flag and retry — needed on Windows for .git pack files."""
|
||||
import stat
|
||||
os.chmod(path, stat.S_IWRITE)
|
||||
func(path)
|
||||
shutil.rmtree(llama_dir, onerror = _force_remove_readonly)
|
||||
unsloth_home.mkdir(parents = True, exist_ok = True)
|
||||
|
||||
result = subprocess.run(
|
||||
["git", "clone", "--depth", "1", "https://github.com/ggml-org/llama.cpp.git", str(llama_dir)],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
[
|
||||
"git",
|
||||
"clone",
|
||||
"--depth",
|
||||
"1",
|
||||
"https://github.com/ggml-org/llama.cpp.git",
|
||||
str(llama_dir),
|
||||
],
|
||||
stdout = subprocess.PIPE,
|
||||
stderr = subprocess.STDOUT,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
typer.echo(" Failed to clone llama.cpp")
|
||||
|
|
@ -245,7 +332,8 @@ def _build_llama_cpp():
|
|||
build_dir = llama_dir / "build"
|
||||
result = subprocess.run(
|
||||
["cmake", "-S", str(llama_dir), "-B", str(build_dir)] + cmake_args,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
stdout = subprocess.PIPE,
|
||||
stderr = subprocess.STDOUT,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
typer.echo(" cmake configure failed")
|
||||
|
|
@ -253,21 +341,42 @@ def _build_llama_cpp():
|
|||
|
||||
ncpu = str(os.cpu_count() or 4)
|
||||
result = subprocess.run(
|
||||
["cmake", "--build", str(build_dir), "--config", "Release",
|
||||
"--target", "llama-server", f"-j{ncpu}"],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
[
|
||||
"cmake",
|
||||
"--build",
|
||||
str(build_dir),
|
||||
"--config",
|
||||
"Release",
|
||||
"--target",
|
||||
"llama-server",
|
||||
f"-j{ncpu}",
|
||||
],
|
||||
stdout = subprocess.PIPE,
|
||||
stderr = subprocess.STDOUT,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
typer.echo(" llama-server build failed")
|
||||
return
|
||||
|
||||
subprocess.run(
|
||||
["cmake", "--build", str(build_dir), "--config", "Release",
|
||||
"--target", "llama-quantize", f"-j{ncpu}"],
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
[
|
||||
"cmake",
|
||||
"--build",
|
||||
str(build_dir),
|
||||
"--config",
|
||||
"Release",
|
||||
"--target",
|
||||
"llama-quantize",
|
||||
f"-j{ncpu}",
|
||||
],
|
||||
stdout = subprocess.PIPE,
|
||||
stderr = subprocess.STDOUT,
|
||||
)
|
||||
|
||||
server_bin = build_dir / "bin" / "llama-server"
|
||||
if sys.platform == "win32":
|
||||
server_bin = build_dir / "bin" / "Release" / "llama-server.exe"
|
||||
else:
|
||||
server_bin = build_dir / "bin" / "llama-server"
|
||||
if server_bin.is_file():
|
||||
typer.echo(f" llama-server built at {server_bin}")
|
||||
else:
|
||||
|
|
|
|||
|
|
@ -17,18 +17,18 @@ def train(
|
|||
None,
|
||||
"--config",
|
||||
"-c",
|
||||
help="Path to YAML/JSON config file. CLI flags override config values.",
|
||||
help = "Path to YAML/JSON config file. CLI flags override config values.",
|
||||
),
|
||||
hf_token: Optional[str] = typer.Option(
|
||||
None, "--hf-token", envvar="HF_TOKEN", help="Hugging Face token if needed."
|
||||
None, "--hf-token", envvar = "HF_TOKEN", help = "Hugging Face token if needed."
|
||||
),
|
||||
wandb_token: Optional[str] = typer.Option(
|
||||
None, "--wandb-token", envvar="WANDB_API_KEY", help="Weights & Biases API key."
|
||||
None, "--wandb-token", envvar = "WANDB_API_KEY", help = "Weights & Biases API key."
|
||||
),
|
||||
dry_run: bool = typer.Option(
|
||||
False,
|
||||
"--dry-run",
|
||||
help="Show resolved config and exit without training.",
|
||||
help = "Show resolved config and exit without training.",
|
||||
),
|
||||
config_overrides: dict = None,
|
||||
):
|
||||
|
|
@ -36,14 +36,15 @@ def train(
|
|||
try:
|
||||
cfg = load_config(config)
|
||||
except FileNotFoundError as e:
|
||||
typer.echo(f"Error: {e}", err=True)
|
||||
raise typer.Exit(code=2)
|
||||
typer.echo(f"Error: {e}", err = True)
|
||||
raise typer.Exit(code = 2)
|
||||
|
||||
cfg.apply_overrides(**config_overrides)
|
||||
|
||||
# CLI/env tokens take precedence over config
|
||||
# Handle case where typer.Option isn't resolved (decorator interaction)
|
||||
from typer.models import OptionInfo
|
||||
|
||||
if isinstance(hf_token, OptionInfo):
|
||||
hf_token = None
|
||||
if isinstance(wandb_token, OptionInfo):
|
||||
|
|
@ -53,33 +54,38 @@ def train(
|
|||
|
||||
if dry_run:
|
||||
import yaml
|
||||
|
||||
data = cfg.model_dump()
|
||||
data["training"]["output_dir"] = str(data["training"]["output_dir"])
|
||||
typer.echo(yaml.dump(data, default_flow_style=False, sort_keys=False))
|
||||
raise typer.Exit(code=0)
|
||||
typer.echo(yaml.dump(data, default_flow_style = False, sort_keys = False))
|
||||
raise typer.Exit(code = 0)
|
||||
|
||||
if not cfg.model:
|
||||
typer.echo("Error: provide --model or set model in --config", err=True)
|
||||
raise typer.Exit(code=2)
|
||||
typer.echo("Error: provide --model or set model in --config", err = True)
|
||||
raise typer.Exit(code = 2)
|
||||
|
||||
if not cfg.data.dataset and not cfg.data.local_dataset:
|
||||
typer.echo(
|
||||
"Error: provide --dataset or --local-dataset (or via --config)", err=True
|
||||
"Error: provide --dataset or --local-dataset (or via --config)", err = True
|
||||
)
|
||||
raise typer.Exit(code=2)
|
||||
raise typer.Exit(code = 2)
|
||||
|
||||
# Check if the model path is a LoRA adapter (has adapter_config.json)
|
||||
model_path = Path(cfg.model) if cfg.model else None
|
||||
model_is_lora = model_path and model_path.is_dir() and (model_path / "adapter_config.json").exists()
|
||||
model_is_lora = (
|
||||
model_path
|
||||
and model_path.is_dir()
|
||||
and (model_path / "adapter_config.json").exists()
|
||||
)
|
||||
use_lora = cfg.training.training_type.lower() == "lora"
|
||||
|
||||
if model_is_lora and not use_lora:
|
||||
typer.echo(
|
||||
"Error: Cannot do full finetuning on a LoRA adapter. "
|
||||
"Use --training-type lora or provide a base model.",
|
||||
err=True,
|
||||
err = True,
|
||||
)
|
||||
raise typer.Exit(code=2)
|
||||
raise typer.Exit(code = 2)
|
||||
|
||||
from studio.backend.core.training.trainer import UnslothTrainer
|
||||
|
||||
|
|
@ -87,38 +93,40 @@ def train(
|
|||
|
||||
# Load model (trainer.is_vlm is set after this)
|
||||
if not trainer.load_model(
|
||||
model_name=cfg.model,
|
||||
max_seq_length=cfg.training.max_seq_length,
|
||||
load_in_4bit=cfg.training.load_in_4bit if use_lora else False,
|
||||
hf_token=hf_token,
|
||||
model_name = cfg.model,
|
||||
max_seq_length = cfg.training.max_seq_length,
|
||||
load_in_4bit = cfg.training.load_in_4bit if use_lora else False,
|
||||
hf_token = hf_token,
|
||||
):
|
||||
typer.echo("Model load failed", err=True)
|
||||
raise typer.Exit(code=1)
|
||||
typer.echo("Model load failed", err = True)
|
||||
raise typer.Exit(code = 1)
|
||||
|
||||
is_vision = trainer.is_vlm
|
||||
|
||||
if not trainer.prepare_model_for_training(**cfg.model_kwargs(use_lora, is_vision)):
|
||||
typer.echo("Model preparation failed", err=True)
|
||||
raise typer.Exit(code=1)
|
||||
typer.echo("Model preparation failed", err = True)
|
||||
raise typer.Exit(code = 1)
|
||||
|
||||
result = trainer.load_and_format_dataset(
|
||||
dataset_source=cfg.data.dataset or "",
|
||||
format_type=cfg.data.format_type,
|
||||
local_datasets=cfg.data.local_dataset,
|
||||
dataset_source = cfg.data.dataset or "",
|
||||
format_type = cfg.data.format_type,
|
||||
local_datasets = cfg.data.local_dataset,
|
||||
)
|
||||
if result is None:
|
||||
typer.echo("Dataset load failed", err=True)
|
||||
raise typer.Exit(code=1)
|
||||
typer.echo("Dataset load failed", err = True)
|
||||
raise typer.Exit(code = 1)
|
||||
|
||||
ds, eval_ds = result
|
||||
|
||||
training_kwargs = cfg.training_kwargs()
|
||||
training_kwargs["wandb_token"] = wandb_token # CLI/env takes precedence
|
||||
started = trainer.start_training(dataset=ds, eval_dataset=eval_ds, **training_kwargs)
|
||||
started = trainer.start_training(
|
||||
dataset = ds, eval_dataset = eval_ds, **training_kwargs
|
||||
)
|
||||
|
||||
if not started:
|
||||
typer.echo("Training failed to start", err=True)
|
||||
raise typer.Exit(code=1)
|
||||
typer.echo("Training failed to start", err = True)
|
||||
raise typer.Exit(code = 1)
|
||||
|
||||
try:
|
||||
while trainer.training_thread and trainer.training_thread.is_alive():
|
||||
|
|
@ -132,5 +140,5 @@ def train(
|
|||
|
||||
final = trainer.get_training_progress()
|
||||
if getattr(final, "error", None):
|
||||
typer.echo(f"Training error: {final.error}", err=True)
|
||||
raise typer.Exit(code=1)
|
||||
typer.echo(f"Training error: {final.error}", err = True)
|
||||
raise typer.Exit(code = 1)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
# 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 os
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
|
@ -9,27 +11,64 @@ import typer
|
|||
|
||||
|
||||
def ui(
|
||||
port: int = typer.Option(8000, "--port", "-p", help="Port to run the UI server on."),
|
||||
host: str = typer.Option("0.0.0.0", "--host", "-H", help="Host address to bind to."),
|
||||
frontend: Optional[Path] = typer.Option(None, "--frontend", "-f", help="Path to frontend build directory."),
|
||||
silent: bool = typer.Option(False, "--silent", "-q", help="Suppress startup messages."),
|
||||
port: int = typer.Option(
|
||||
8000, "--port", "-p", help = "Port to run the UI server on."
|
||||
),
|
||||
host: str = typer.Option(
|
||||
"0.0.0.0", "--host", "-H", help = "Host address to bind to."
|
||||
),
|
||||
frontend: Optional[Path] = typer.Option(
|
||||
None, "--frontend", "-f", help = "Path to frontend build directory."
|
||||
),
|
||||
silent: bool = typer.Option(
|
||||
False, "--silent", "-q", help = "Suppress startup messages."
|
||||
),
|
||||
):
|
||||
"""Launch the Unsloth web UI backend server."""
|
||||
"""Launch the Unsloth web UI backend server (alias for 'unsloth studio')."""
|
||||
from cli.commands.studio import _studio_venv_python, _find_run_py, STUDIO_HOME
|
||||
|
||||
# Re-execute in studio venv if available and not already inside it
|
||||
studio_venv_dir = STUDIO_HOME / ".venv"
|
||||
in_studio_venv = sys.prefix.startswith(str(studio_venv_dir))
|
||||
|
||||
if not in_studio_venv:
|
||||
studio_python = _studio_venv_python()
|
||||
run_py = _find_run_py()
|
||||
if studio_python and run_py:
|
||||
if not silent:
|
||||
typer.echo("Launching with studio venv...")
|
||||
args = [
|
||||
str(studio_python),
|
||||
str(run_py),
|
||||
"--host",
|
||||
host,
|
||||
"--port",
|
||||
str(port),
|
||||
]
|
||||
if frontend:
|
||||
args.extend(["--frontend", str(frontend)])
|
||||
if silent:
|
||||
args.append("--silent")
|
||||
os.execvp(str(studio_python), args)
|
||||
else:
|
||||
typer.echo("Studio not set up. Run 'unsloth studio setup' first.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
from studio.backend.run import run_server
|
||||
|
||||
if not silent:
|
||||
from studio.backend.run import _resolve_external_ip
|
||||
|
||||
display_host = _resolve_external_ip() if host == "0.0.0.0" else host
|
||||
typer.echo(f"Starting Unsloth Studio on http://{display_host}:{port}")
|
||||
|
||||
run_server(
|
||||
host=host,
|
||||
port=port,
|
||||
frontend_path=frontend,
|
||||
silent=silent,
|
||||
host = host,
|
||||
port = port,
|
||||
frontend_path = frontend,
|
||||
silent = silent,
|
||||
)
|
||||
|
||||
# Keep running until interrupted
|
||||
try:
|
||||
while True:
|
||||
time.sleep(1)
|
||||
|
|
|
|||
|
|
@ -58,10 +58,10 @@ class LoggingConfig(BaseModel):
|
|||
|
||||
class Config(BaseModel):
|
||||
model: Optional[str] = None
|
||||
data: DataConfig = Field(default_factory=DataConfig)
|
||||
training: TrainingConfig = Field(default_factory=TrainingConfig)
|
||||
lora: LoraConfig = Field(default_factory=LoraConfig)
|
||||
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
||||
data: DataConfig = Field(default_factory = DataConfig)
|
||||
training: TrainingConfig = Field(default_factory = TrainingConfig)
|
||||
lora: LoraConfig = Field(default_factory = LoraConfig)
|
||||
logging: LoggingConfig = Field(default_factory = LoggingConfig)
|
||||
|
||||
def apply_overrides(self, **kwargs):
|
||||
"""Apply CLI overrides by matching arg names to config fields."""
|
||||
|
|
@ -83,7 +83,11 @@ class Config(BaseModel):
|
|||
# Vision models expect a string (e.g., "all-linear"); fall back to None to use trainer defaults
|
||||
target_modules = "all-linear" if self.lora.vision_all_linear else None
|
||||
else:
|
||||
parsed = [m.strip() for m in str(self.lora.target_modules).split(",") if m and m.strip()]
|
||||
parsed = [
|
||||
m.strip()
|
||||
for m in str(self.lora.target_modules).split(",")
|
||||
if m and m.strip()
|
||||
]
|
||||
target_modules = parsed or None
|
||||
|
||||
return {
|
||||
|
|
@ -134,11 +138,12 @@ def load_config(path: Optional[Path]) -> Config:
|
|||
if not path.exists():
|
||||
raise FileNotFoundError(f"Config file not found: {path}")
|
||||
|
||||
text = path.read_text(encoding="utf-8")
|
||||
text = path.read_text(encoding = "utf-8")
|
||||
if path.suffix.lower() in {".yaml", ".yml"}:
|
||||
data = yaml.safe_load(text) or {}
|
||||
else:
|
||||
import json
|
||||
|
||||
data = json.loads(text or "{}")
|
||||
|
||||
return Config(**data)
|
||||
|
|
|
|||
|
|
@ -80,7 +80,9 @@ def add_options_from_config(config_class: type[BaseModel]) -> Callable:
|
|||
which will receive a dict of all CLI-provided config values.
|
||||
"""
|
||||
fields = _collect_config_fields(config_class)
|
||||
field_names = {name for name, field_info in fields if not _is_list_type(field_info.annotation)}
|
||||
field_names = {
|
||||
name for name, field_info in fields if not _is_list_type(field_info.annotation)
|
||||
}
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
sig = inspect.signature(func)
|
||||
|
|
@ -105,22 +107,22 @@ def add_options_from_config(config_class: type[BaseModel]) -> Callable:
|
|||
default = typer.Option(
|
||||
None,
|
||||
f"{flag_name}/--no-{field_name.replace('_', '-')}",
|
||||
help=help_text,
|
||||
help = help_text,
|
||||
)
|
||||
param = inspect.Parameter(
|
||||
field_name,
|
||||
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
||||
default=default,
|
||||
annotation=Optional[bool],
|
||||
default = default,
|
||||
annotation = Optional[bool],
|
||||
)
|
||||
else:
|
||||
py_type = _get_python_type(annotation)
|
||||
default = typer.Option(None, flag_name, help=help_text)
|
||||
default = typer.Option(None, flag_name, help = help_text)
|
||||
param = inspect.Parameter(
|
||||
field_name,
|
||||
inspect.Parameter.POSITIONAL_OR_KEYWORD,
|
||||
default=default,
|
||||
annotation=Optional[py_type],
|
||||
default = default,
|
||||
annotation = Optional[py_type],
|
||||
)
|
||||
new_params.append(param)
|
||||
|
||||
|
|
@ -129,7 +131,7 @@ def add_options_from_config(config_class: type[BaseModel]) -> Callable:
|
|||
if param.name != "config_overrides":
|
||||
new_params.append(param)
|
||||
|
||||
new_sig = sig.replace(parameters=new_params)
|
||||
new_sig = sig.replace(parameters = new_params)
|
||||
|
||||
@functools.wraps(func)
|
||||
def wrapper(*args, **kwargs):
|
||||
|
|
|
|||
|
|
@ -1,5 +1,17 @@
|
|||
{
|
||||
"cells": [
|
||||
{
|
||||
"cell_type": "markdown",
|
||||
"id": "f2b0c6a1",
|
||||
"metadata": {},
|
||||
"source": [
|
||||
"**License Notice**\n",
|
||||
"\n",
|
||||
"SPDX-License-Identifier: AGPL-3.0-only\n",
|
||||
"\n",
|
||||
"Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0\n"
|
||||
]
|
||||
},
|
||||
{
|
||||
"cell_type": "code",
|
||||
"execution_count": null,
|
||||
2
studio/__init__.py
Normal file
2
studio/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
2
studio/backend/__init__.py
Normal file
2
studio/backend/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
2
studio/backend/assets/__init__.py
Normal file
2
studio/backend/assets/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
2
studio/backend/assets/configs/__init__.py
Normal file
2
studio/backend/assets/configs/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
"""
|
||||
Authentication module for JWT-based auth with SQLite storage.
|
||||
"""
|
||||
|
||||
from .authentication import (
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
|
|
@ -44,6 +45,3 @@ __all__ = [
|
|||
"hash_password",
|
||||
"verify_password",
|
||||
]
|
||||
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -38,10 +38,10 @@ def create_access_token(
|
|||
"""
|
||||
to_encode = {"sub": subject}
|
||||
expire = datetime.now(timezone.utc) + (
|
||||
expires_delta or timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
expires_delta or timedelta(minutes = ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
)
|
||||
to_encode.update({"exp": expire})
|
||||
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
||||
return jwt.encode(to_encode, SECRET_KEY, algorithm = ALGORITHM)
|
||||
|
||||
|
||||
def create_refresh_token(subject: str) -> str:
|
||||
|
|
@ -51,7 +51,7 @@ def create_refresh_token(subject: str) -> str:
|
|||
Refresh tokens are opaque (not JWTs) and expire after REFRESH_TOKEN_EXPIRE_DAYS.
|
||||
"""
|
||||
token = secrets.token_urlsafe(48)
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(days=REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
expires_at = datetime.now(timezone.utc) + timedelta(days = REFRESH_TOKEN_EXPIRE_DAYS)
|
||||
save_refresh_token(token, subject, expires_at.isoformat())
|
||||
return token
|
||||
|
||||
|
|
@ -66,13 +66,13 @@ def refresh_access_token(refresh_token: str) -> Optional[str]:
|
|||
username = verify_refresh_token(refresh_token)
|
||||
if username is None:
|
||||
return None
|
||||
return create_access_token(subject=username)
|
||||
return create_access_token(subject = username)
|
||||
|
||||
|
||||
def reload_secret() -> None:
|
||||
"""
|
||||
Reload the JWT secret from SQLite.
|
||||
|
||||
|
||||
Call this after setup to ensure new tokens use the persistent secret.
|
||||
"""
|
||||
global SECRET_KEY
|
||||
|
|
@ -93,16 +93,16 @@ async def get_current_subject(
|
|||
"""
|
||||
token = credentials.credentials
|
||||
try:
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms = [ALGORITHM])
|
||||
subject: Optional[str] = payload.get("sub")
|
||||
if subject is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token payload",
|
||||
status_code = status.HTTP_401_UNAUTHORIZED,
|
||||
detail = "Invalid token payload",
|
||||
)
|
||||
return subject
|
||||
except jwt.InvalidTokenError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired token",
|
||||
status_code = status.HTTP_401_UNAUTHORIZED,
|
||||
detail = "Invalid or expired token",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"""
|
||||
Password hashing utilities using PBKDF2.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
|
|
@ -13,7 +14,7 @@ from typing import Tuple
|
|||
def hash_password(password: str, salt: str | None = None) -> Tuple[str, str]:
|
||||
"""
|
||||
Hash a password using PBKDF2-HMAC-SHA256.
|
||||
|
||||
|
||||
Returns (salt, hex_hash) tuple.
|
||||
"""
|
||||
if salt is None:
|
||||
|
|
@ -30,7 +31,7 @@ def hash_password(password: str, salt: str | None = None) -> Tuple[str, str]:
|
|||
def verify_password(password: str, salt: str, hashed: str) -> bool:
|
||||
"""
|
||||
Verify a password against a stored salt and hash.
|
||||
|
||||
|
||||
Uses constant-time comparison to prevent timing attacks.
|
||||
"""
|
||||
dk = hashlib.pbkdf2_hmac(
|
||||
|
|
@ -40,4 +41,3 @@ def verify_password(password: str, salt: str, hashed: str) -> bool:
|
|||
100_000,
|
||||
)
|
||||
return hmac.compare_digest(dk.hex(), hashed)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"""
|
||||
SQLite storage for authentication data (user credentials + JWT secret).
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
|
|
@ -69,11 +70,11 @@ def is_initialized() -> bool:
|
|||
def create_initial_user(username: str, password: str, jwt_secret: str) -> None:
|
||||
"""
|
||||
Create the initial admin user in the database.
|
||||
|
||||
|
||||
Raises sqlite3.IntegrityError if username already exists.
|
||||
"""
|
||||
from .hashing import hash_password
|
||||
|
||||
|
||||
salt, pwd_hash = hash_password(password)
|
||||
conn = get_connection()
|
||||
try:
|
||||
|
|
@ -92,7 +93,7 @@ def create_initial_user(username: str, password: str, jwt_secret: str) -> None:
|
|||
def delete_user(username: str) -> None:
|
||||
"""
|
||||
Delete a user from the database.
|
||||
|
||||
|
||||
Used for rollback when setup fails after user creation.
|
||||
"""
|
||||
conn = get_connection()
|
||||
|
|
@ -106,7 +107,7 @@ def delete_user(username: str) -> None:
|
|||
def get_user_and_secret(username: str) -> Optional[Tuple[str, str, str]]:
|
||||
"""
|
||||
Get user's password salt, hash, and JWT secret.
|
||||
|
||||
|
||||
Returns (password_salt, password_hash, jwt_secret) or None if user not found.
|
||||
"""
|
||||
conn = get_connection()
|
||||
|
|
@ -130,7 +131,7 @@ def get_user_and_secret(username: str) -> Optional[Tuple[str, str, str]]:
|
|||
def load_jwt_secret() -> str:
|
||||
"""
|
||||
Load the JWT secret from the database.
|
||||
|
||||
|
||||
Raises RuntimeError if auth is not initialized.
|
||||
"""
|
||||
conn = get_connection()
|
||||
|
|
@ -138,7 +139,9 @@ def load_jwt_secret() -> str:
|
|||
cur = conn.execute("SELECT jwt_secret FROM auth_user LIMIT 1")
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise RuntimeError("Auth is not initialized. Please set up a password first.")
|
||||
raise RuntimeError(
|
||||
"Auth is not initialized. Please set up a password first."
|
||||
)
|
||||
return row["jwt_secret"]
|
||||
finally:
|
||||
conn.close()
|
||||
|
|
@ -161,7 +164,7 @@ def save_setup_token(token: str) -> None:
|
|||
def consume_setup_token(token: str) -> bool:
|
||||
"""
|
||||
Verify a setup token and delete it if valid.
|
||||
|
||||
|
||||
Returns True if the token was valid (and is now consumed), False otherwise.
|
||||
"""
|
||||
token_hash = _hash_token(token)
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
Colab-specific helpers for running Unsloth Studio.
|
||||
Uses Colab's built-in proxy - no external tunneling needed!
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
|
|
@ -14,20 +15,19 @@ if backend_path not in sys.path:
|
|||
sys.path.insert(0, backend_path)
|
||||
|
||||
from loggers import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
|
||||
|
||||
def get_colab_url(port: int = 8000) -> str:
|
||||
"""
|
||||
Get the actual Colab proxy URL for a port.
|
||||
"""
|
||||
try:
|
||||
from google.colab.output import eval_js
|
||||
|
||||
|
||||
# Use Colab's proxy mechanism
|
||||
url = eval_js(f"google.colab.kernel.proxyPort({port})", timeout_sec=5)
|
||||
url = eval_js(f"google.colab.kernel.proxyPort({port})", timeout_sec = 5)
|
||||
return url if url else f"http://localhost:{port}"
|
||||
except Exception as e:
|
||||
logger.info(f"Note: Could not get Colab URL ({e})")
|
||||
|
|
@ -37,10 +37,10 @@ def get_colab_url(port: int = 8000) -> str:
|
|||
def show_link(port: int = 8000):
|
||||
"""Display a styled clickable link to the UI."""
|
||||
from IPython.display import display, HTML
|
||||
|
||||
|
||||
# Get real Colab proxy URL
|
||||
url = get_colab_url(port)
|
||||
|
||||
|
||||
html = f"""
|
||||
<div style="padding: 20px; background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
|
||||
border-radius: 12px; margin: 10px 0; font-family: system-ui, -apple-system, sans-serif;">
|
||||
|
|
@ -65,32 +65,32 @@ def show_link(port: int = 8000):
|
|||
def start(port: int = 8000):
|
||||
"""
|
||||
Start Unsloth Studio server in Colab and display the URL.
|
||||
|
||||
|
||||
Usage:
|
||||
from colab import start
|
||||
start()
|
||||
"""
|
||||
import sys
|
||||
|
||||
|
||||
logger.info("🦥 Starting Unsloth Studio...")
|
||||
|
||||
|
||||
logger.info(" Loading backend...")
|
||||
from run import run_server
|
||||
|
||||
|
||||
# Auto-detect frontend path
|
||||
repo_root = Path(__file__).parent.parent
|
||||
frontend_path = repo_root / "frontend" / "dist"
|
||||
|
||||
|
||||
if not frontend_path.exists():
|
||||
logger.info("❌ Frontend not built! Please run the setup cell first.")
|
||||
return
|
||||
|
||||
|
||||
logger.info(" Starting server...")
|
||||
# Start server silently
|
||||
run_server(host="0.0.0.0", port=port, frontend_path=frontend_path, silent=True)
|
||||
|
||||
run_server(host = "0.0.0.0", port = port, frontend_path = frontend_path, silent = True)
|
||||
|
||||
logger.info(" Server started!")
|
||||
|
||||
|
||||
# Show the clickable link with real URL
|
||||
show_link(port)
|
||||
|
||||
|
|
|
|||
|
|
@ -12,101 +12,123 @@ code has a chance to run.
|
|||
|
||||
__all__ = [
|
||||
# Inference
|
||||
'InferenceBackend',
|
||||
'get_inference_backend',
|
||||
|
||||
"InferenceBackend",
|
||||
"get_inference_backend",
|
||||
# Training
|
||||
'get_training_backend',
|
||||
'TrainingBackend',
|
||||
'TrainingProgress',
|
||||
|
||||
"get_training_backend",
|
||||
"TrainingBackend",
|
||||
"TrainingProgress",
|
||||
# Config
|
||||
'ModelConfig',
|
||||
'is_vision_model',
|
||||
'scan_trained_loras',
|
||||
'load_model_defaults',
|
||||
'get_base_model_from_lora',
|
||||
|
||||
"ModelConfig",
|
||||
"is_vision_model",
|
||||
"scan_trained_loras",
|
||||
"load_model_defaults",
|
||||
"get_base_model_from_lora",
|
||||
# Utils
|
||||
'format_and_template_dataset',
|
||||
'normalize_path',
|
||||
'is_local_path',
|
||||
'is_model_cached',
|
||||
'without_hf_auth',
|
||||
'format_error_message',
|
||||
'get_gpu_memory_info',
|
||||
'log_gpu_memory',
|
||||
'get_device',
|
||||
'is_apple_silicon',
|
||||
'clear_gpu_cache',
|
||||
'DeviceType',
|
||||
"format_and_template_dataset",
|
||||
"normalize_path",
|
||||
"is_local_path",
|
||||
"is_model_cached",
|
||||
"without_hf_auth",
|
||||
"format_error_message",
|
||||
"get_gpu_memory_info",
|
||||
"log_gpu_memory",
|
||||
"get_device",
|
||||
"is_apple_silicon",
|
||||
"clear_gpu_cache",
|
||||
"DeviceType",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name):
|
||||
# Inference
|
||||
if name in ('InferenceBackend', 'get_inference_backend'):
|
||||
if name in ("InferenceBackend", "get_inference_backend"):
|
||||
from .inference import InferenceBackend, get_inference_backend
|
||||
globals()['InferenceBackend'] = InferenceBackend
|
||||
globals()['get_inference_backend'] = get_inference_backend
|
||||
|
||||
globals()["InferenceBackend"] = InferenceBackend
|
||||
globals()["get_inference_backend"] = get_inference_backend
|
||||
return globals()[name]
|
||||
|
||||
# Training
|
||||
if name in ('TrainingBackend', 'get_training_backend', 'TrainingProgress'):
|
||||
if name in ("TrainingBackend", "get_training_backend", "TrainingProgress"):
|
||||
from .training import TrainingBackend, get_training_backend, TrainingProgress
|
||||
globals()['TrainingBackend'] = TrainingBackend
|
||||
globals()['get_training_backend'] = get_training_backend
|
||||
globals()['TrainingProgress'] = TrainingProgress
|
||||
|
||||
globals()["TrainingBackend"] = TrainingBackend
|
||||
globals()["get_training_backend"] = get_training_backend
|
||||
globals()["TrainingProgress"] = TrainingProgress
|
||||
return globals()[name]
|
||||
|
||||
# Config (from utils.models)
|
||||
if name in ('is_vision_model', 'ModelConfig', 'scan_trained_loras',
|
||||
'load_model_defaults', 'get_base_model_from_lora'):
|
||||
if name in (
|
||||
"is_vision_model",
|
||||
"ModelConfig",
|
||||
"scan_trained_loras",
|
||||
"load_model_defaults",
|
||||
"get_base_model_from_lora",
|
||||
):
|
||||
from utils.models import (
|
||||
is_vision_model, ModelConfig, scan_trained_loras,
|
||||
load_model_defaults, get_base_model_from_lora,
|
||||
is_vision_model,
|
||||
ModelConfig,
|
||||
scan_trained_loras,
|
||||
load_model_defaults,
|
||||
get_base_model_from_lora,
|
||||
)
|
||||
globals()['is_vision_model'] = is_vision_model
|
||||
globals()['ModelConfig'] = ModelConfig
|
||||
globals()['scan_trained_loras'] = scan_trained_loras
|
||||
globals()['load_model_defaults'] = load_model_defaults
|
||||
globals()['get_base_model_from_lora'] = get_base_model_from_lora
|
||||
|
||||
globals()["is_vision_model"] = is_vision_model
|
||||
globals()["ModelConfig"] = ModelConfig
|
||||
globals()["scan_trained_loras"] = scan_trained_loras
|
||||
globals()["load_model_defaults"] = load_model_defaults
|
||||
globals()["get_base_model_from_lora"] = get_base_model_from_lora
|
||||
return globals()[name]
|
||||
|
||||
# Paths
|
||||
if name in ('normalize_path', 'is_local_path', 'is_model_cached'):
|
||||
if name in ("normalize_path", "is_local_path", "is_model_cached"):
|
||||
from utils.paths import normalize_path, is_local_path, is_model_cached
|
||||
globals()['normalize_path'] = normalize_path
|
||||
globals()['is_local_path'] = is_local_path
|
||||
globals()['is_model_cached'] = is_model_cached
|
||||
|
||||
globals()["normalize_path"] = normalize_path
|
||||
globals()["is_local_path"] = is_local_path
|
||||
globals()["is_model_cached"] = is_model_cached
|
||||
return globals()[name]
|
||||
|
||||
# Utils
|
||||
if name in ('without_hf_auth', 'format_error_message'):
|
||||
if name in ("without_hf_auth", "format_error_message"):
|
||||
from utils.utils import without_hf_auth, format_error_message
|
||||
globals()['without_hf_auth'] = without_hf_auth
|
||||
globals()['format_error_message'] = format_error_message
|
||||
|
||||
globals()["without_hf_auth"] = without_hf_auth
|
||||
globals()["format_error_message"] = format_error_message
|
||||
return globals()[name]
|
||||
|
||||
# Hardware
|
||||
if name in ('get_device', 'is_apple_silicon', 'clear_gpu_cache',
|
||||
'get_gpu_memory_info', 'log_gpu_memory', 'DeviceType'):
|
||||
if name in (
|
||||
"get_device",
|
||||
"is_apple_silicon",
|
||||
"clear_gpu_cache",
|
||||
"get_gpu_memory_info",
|
||||
"log_gpu_memory",
|
||||
"DeviceType",
|
||||
):
|
||||
from utils.hardware import (
|
||||
get_device, is_apple_silicon, clear_gpu_cache,
|
||||
get_gpu_memory_info, log_gpu_memory, DeviceType,
|
||||
get_device,
|
||||
is_apple_silicon,
|
||||
clear_gpu_cache,
|
||||
get_gpu_memory_info,
|
||||
log_gpu_memory,
|
||||
DeviceType,
|
||||
)
|
||||
globals()['get_device'] = get_device
|
||||
globals()['is_apple_silicon'] = is_apple_silicon
|
||||
globals()['clear_gpu_cache'] = clear_gpu_cache
|
||||
globals()['get_gpu_memory_info'] = get_gpu_memory_info
|
||||
globals()['log_gpu_memory'] = log_gpu_memory
|
||||
globals()['DeviceType'] = DeviceType
|
||||
|
||||
globals()["get_device"] = get_device
|
||||
globals()["is_apple_silicon"] = is_apple_silicon
|
||||
globals()["clear_gpu_cache"] = clear_gpu_cache
|
||||
globals()["get_gpu_memory_info"] = get_gpu_memory_info
|
||||
globals()["log_gpu_memory"] = log_gpu_memory
|
||||
globals()["DeviceType"] = DeviceType
|
||||
return globals()[name]
|
||||
|
||||
# Datasets
|
||||
if name == 'format_and_template_dataset':
|
||||
if name == "format_and_template_dataset":
|
||||
from utils.datasets import format_and_template_dataset
|
||||
globals()['format_and_template_dataset'] = format_and_template_dataset
|
||||
|
||||
globals()["format_and_template_dataset"] = format_and_template_dataset
|
||||
return format_and_template_dataset
|
||||
|
||||
raise AttributeError(f"module 'core' has no attribute {name!r}")
|
||||
|
|
|
|||
|
|
@ -4,4 +4,3 @@
|
|||
from .manager import JobManager, get_job_manager
|
||||
|
||||
__all__ = ["JobManager", "get_job_manager"]
|
||||
|
||||
|
|
|
|||
|
|
@ -52,12 +52,10 @@ class Subscription:
|
|||
if event_id is None:
|
||||
self._next_id += 1
|
||||
event_id = self._next_id
|
||||
body = json.dumps(event, separators=(",", ":"), ensure_ascii=False)
|
||||
body = json.dumps(event, separators = (",", ":"), ensure_ascii = False)
|
||||
event_type = event.get("type") or "message"
|
||||
return (
|
||||
f"id: {event_id}\n"
|
||||
f"event: {event_type}\n"
|
||||
f"data: {body}\n\n"
|
||||
f"id: {event_id}\n" f"event: {event_type}\n" f"data: {body}\n\n"
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
|
|
@ -68,7 +66,7 @@ class JobManager:
|
|||
self._job: Job | None = None
|
||||
self._proc: mp.Process | None = None
|
||||
self._mp_q: Any | None = None
|
||||
self._events: deque[dict] = deque(maxlen=5000)
|
||||
self._events: deque[dict] = deque(maxlen = 5000)
|
||||
self._subs: list[queue.Queue] = []
|
||||
self._pump_thread: threading.Thread | None = None
|
||||
self._seq: int = 0
|
||||
|
|
@ -92,7 +90,7 @@ class JobManager:
|
|||
raise RuntimeError("job already running")
|
||||
|
||||
job_id = uuid.uuid4().hex
|
||||
self._job = Job(job_id=job_id, status="pending", started_at=time.time())
|
||||
self._job = Job(job_id = job_id, status = "pending", started_at = time.time())
|
||||
self._job.progress_columns_total = llm_column_count
|
||||
self._events.clear()
|
||||
self._seq = 0
|
||||
|
|
@ -101,18 +99,20 @@ class JobManager:
|
|||
run_payload["_job_id"] = job_id
|
||||
mp_q = _CTX.Queue()
|
||||
proc = _CTX.Process(
|
||||
target=run_job_process,
|
||||
kwargs={"event_queue": mp_q, "recipe": recipe, "run": run_payload},
|
||||
daemon=True,
|
||||
target = run_job_process,
|
||||
kwargs = {"event_queue": mp_q, "recipe": recipe, "run": run_payload},
|
||||
daemon = True,
|
||||
)
|
||||
proc.start()
|
||||
|
||||
self._mp_q = mp_q
|
||||
self._proc = proc
|
||||
self._pump_thread = threading.Thread(target=self._pump_loop, daemon=True)
|
||||
self._pump_thread = threading.Thread(target = self._pump_loop, daemon = True)
|
||||
self._pump_thread.start()
|
||||
|
||||
self._emit({"type": EVENT_JOB_ENQUEUED, "ts": time.time(), "job_id": job_id})
|
||||
self._emit(
|
||||
{"type": EVENT_JOB_ENQUEUED, "ts": time.time(), "job_id": job_id}
|
||||
)
|
||||
return job_id
|
||||
|
||||
def cancel(self, job_id: str) -> bool:
|
||||
|
|
@ -123,7 +123,9 @@ class JobManager:
|
|||
if self._proc is None or not self._proc.is_alive():
|
||||
return True
|
||||
self._job.status = "cancelling"
|
||||
self._emit({"type": EVENT_JOB_CANCELLING, "ts": time.time(), "job_id": job_id})
|
||||
self._emit(
|
||||
{"type": EVENT_JOB_CANCELLING, "ts": time.time(), "job_id": job_id}
|
||||
)
|
||||
try:
|
||||
self._proc.terminate()
|
||||
except (AttributeError, OSError):
|
||||
|
|
@ -225,7 +227,7 @@ class JobManager:
|
|||
|
||||
if in_memory_dataset is not None:
|
||||
total = len(in_memory_dataset)
|
||||
rows = in_memory_dataset[offset:offset + limit]
|
||||
rows = in_memory_dataset[offset : offset + limit]
|
||||
return {"dataset": rows, "total": total}
|
||||
if not artifact_path:
|
||||
if job_status in {"completed", "error", "cancelled"}:
|
||||
|
|
@ -238,7 +240,9 @@ class JobManager:
|
|||
if not parquet_dir.exists():
|
||||
return {"error": f"dataset path missing: {parquet_dir}"}
|
||||
|
||||
return self._load_dataset_page(parquet_dir=parquet_dir, limit=limit, offset=offset)
|
||||
return self._load_dataset_page(
|
||||
parquet_dir = parquet_dir, limit = limit, offset = offset
|
||||
)
|
||||
except Exception as exc:
|
||||
return {"error": f"dataset load failed: {exc}"}
|
||||
|
||||
|
|
@ -250,16 +254,16 @@ class JobManager:
|
|||
offset: int,
|
||||
) -> dict[str, Any]:
|
||||
dataset_page = JobManager._load_dataset_page_with_duckdb(
|
||||
parquet_dir=parquet_dir,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
parquet_dir = parquet_dir,
|
||||
limit = limit,
|
||||
offset = offset,
|
||||
)
|
||||
if dataset_page is not None:
|
||||
return dataset_page
|
||||
return JobManager._load_dataset_page_with_data_designer(
|
||||
parquet_dir=parquet_dir,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
parquet_dir = parquet_dir,
|
||||
limit = limit,
|
||||
offset = offset,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -299,9 +303,9 @@ class JobManager:
|
|||
|
||||
for helper_col in ("filename", "__row_num__"):
|
||||
if helper_col in dataframe.columns:
|
||||
dataframe = dataframe.drop(columns=[helper_col])
|
||||
dataframe = dataframe.drop(columns = [helper_col])
|
||||
|
||||
rows = dataframe.to_dict(orient="records")
|
||||
rows = dataframe.to_dict(orient = "records")
|
||||
return {"dataset": to_preview_jsonable(rows), "total": total}
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -315,21 +319,23 @@ class JobManager:
|
|||
|
||||
dataframe = read_parquet_dataset(parquet_dir)
|
||||
total = int(len(dataframe.index))
|
||||
rows = dataframe.iloc[offset:offset + limit].to_dict(orient="records")
|
||||
rows = dataframe.iloc[offset : offset + limit].to_dict(orient = "records")
|
||||
return {"dataset": to_preview_jsonable(rows), "total": total}
|
||||
|
||||
def subscribe(self, job_id: str, *, after_seq: int | None = None) -> Subscription | None:
|
||||
def subscribe(
|
||||
self, job_id: str, *, after_seq: int | None = None
|
||||
) -> Subscription | None:
|
||||
"""SSE subscribe: get replay buffer + live events stream."""
|
||||
with self._lock:
|
||||
if self._job is None or self._job.job_id != job_id:
|
||||
return None
|
||||
q: queue.Queue = queue.Queue(maxsize=2000)
|
||||
q: queue.Queue = queue.Queue(maxsize = 2000)
|
||||
self._subs.append(q)
|
||||
if after_seq is None:
|
||||
replay = list(self._events)
|
||||
else:
|
||||
replay = [e for e in self._events if int(e.get("seq") or 0) > after_seq]
|
||||
return Subscription(replay=replay, _q=q)
|
||||
return Subscription(replay = replay, _q = q)
|
||||
|
||||
def unsubscribe(self, sub: Subscription) -> None:
|
||||
"""Drop SSE subscriber (client disconnected)."""
|
||||
|
|
@ -361,7 +367,7 @@ class JobManager:
|
|||
def _read_queue_with_timeout(q: Any, *, timeout_sec: float) -> dict | None:
|
||||
"""Try read 1 event from mp queue. Timeout = pump stays responsive."""
|
||||
try:
|
||||
return coerce_event(q.get(timeout=timeout_sec))
|
||||
return coerce_event(q.get(timeout = timeout_sec))
|
||||
except queue.Empty:
|
||||
return None
|
||||
except (EOFError, OSError, ValueError):
|
||||
|
|
@ -387,7 +393,7 @@ class JobManager:
|
|||
return
|
||||
job, proc, mp_q = snap
|
||||
|
||||
event = self._read_queue_with_timeout(mp_q, timeout_sec=0.25)
|
||||
event = self._read_queue_with_timeout(mp_q, timeout_sec = 0.25)
|
||||
if event is not None:
|
||||
self._handle_event(job, event)
|
||||
continue
|
||||
|
|
@ -399,7 +405,11 @@ class JobManager:
|
|||
self._handle_event(job, e)
|
||||
|
||||
with self._lock:
|
||||
if self._job and self._job.status in {"pending", "active", "cancelling"}:
|
||||
if self._job and self._job.status in {
|
||||
"pending",
|
||||
"active",
|
||||
"cancelling",
|
||||
}:
|
||||
if self._job.status == "cancelling":
|
||||
self._job.status = "cancelled"
|
||||
else:
|
||||
|
|
@ -407,9 +417,17 @@ class JobManager:
|
|||
self._job.error = self._job.error or "process exited"
|
||||
self._job.finished_at = time.time()
|
||||
event_type = (
|
||||
EVENT_JOB_CANCELLED if self._job.status == "cancelled" else EVENT_JOB_ERROR
|
||||
EVENT_JOB_CANCELLED
|
||||
if self._job.status == "cancelled"
|
||||
else EVENT_JOB_ERROR
|
||||
)
|
||||
self._emit(
|
||||
{
|
||||
"type": event_type,
|
||||
"ts": time.time(),
|
||||
"job_id": self._job.job_id,
|
||||
}
|
||||
)
|
||||
self._emit({"type": event_type, "ts": time.time(), "job_id": self._job.job_id})
|
||||
return
|
||||
|
||||
def _handle_event(self, job: Job, event: dict) -> None:
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ from .constants import (
|
|||
from .types import Job, ModelUsage, Progress
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@dataclass(frozen = True)
|
||||
class ParsedUpdate:
|
||||
stage: str | None = None
|
||||
current_column: str | None = None
|
||||
|
|
@ -42,6 +42,7 @@ class ParsedUpdate:
|
|||
usage_rpm: float | None = None
|
||||
usage_section_start: bool | None = None
|
||||
|
||||
|
||||
# kinda of a bummber but currently only option, Best effort parser from data-designer logs -> structured status for UI.
|
||||
_RE_SAMPLERS = re.compile(
|
||||
r"Preparing samplers to generate (?P<rows>\d+) records across (?P<cols>\d+) columns"
|
||||
|
|
@ -66,76 +67,76 @@ def parse_log_message(msg: str) -> ParsedUpdate | None:
|
|||
m = _RE_SAMPLERS.search(msg)
|
||||
if m:
|
||||
return ParsedUpdate(
|
||||
stage=STAGE_SAMPLING,
|
||||
rows=int(m.group("rows")),
|
||||
cols=int(m.group("cols")),
|
||||
stage = STAGE_SAMPLING,
|
||||
rows = int(m.group("rows")),
|
||||
cols = int(m.group("cols")),
|
||||
)
|
||||
|
||||
if "Sorting column configs into a Directed Acyclic Graph" in msg:
|
||||
return ParsedUpdate(stage=STAGE_DAG)
|
||||
return ParsedUpdate(stage = STAGE_DAG)
|
||||
if "Running health checks for models" in msg:
|
||||
return ParsedUpdate(stage=STAGE_HEALTHCHECK)
|
||||
return ParsedUpdate(stage = STAGE_HEALTHCHECK)
|
||||
if "Preview generation in progress" in msg:
|
||||
return ParsedUpdate(stage=STAGE_PREVIEW)
|
||||
return ParsedUpdate(stage = STAGE_PREVIEW)
|
||||
if "Creating Data Designer dataset" in msg:
|
||||
return ParsedUpdate(stage=STAGE_CREATE)
|
||||
return ParsedUpdate(stage = STAGE_CREATE)
|
||||
if "Measuring dataset column statistics" in msg:
|
||||
return ParsedUpdate(stage=STAGE_PROFILING)
|
||||
return ParsedUpdate(stage = STAGE_PROFILING)
|
||||
|
||||
m = _RE_COLCFG.search(msg)
|
||||
if m:
|
||||
col = m.group("col")
|
||||
return ParsedUpdate(stage=STAGE_COLUMN_CONFIG, current_column=col)
|
||||
return ParsedUpdate(stage = STAGE_COLUMN_CONFIG, current_column = col)
|
||||
|
||||
m = _RE_PROCESSING_COL.search(msg)
|
||||
if m:
|
||||
col = m.group("col")
|
||||
return ParsedUpdate(stage=STAGE_GENERATING, current_column=col)
|
||||
return ParsedUpdate(stage = STAGE_GENERATING, current_column = col)
|
||||
|
||||
m = _RE_PROGRESS.search(msg)
|
||||
if m:
|
||||
p = Progress(
|
||||
done=int(m.group("done")),
|
||||
total=int(m.group("total")),
|
||||
percent=float(m.group("pct")),
|
||||
ok=int(m.group("ok")),
|
||||
failed=int(m.group("failed")),
|
||||
rate=float(m.group("rate")),
|
||||
eta_sec=float(m.group("eta")),
|
||||
done = int(m.group("done")),
|
||||
total = int(m.group("total")),
|
||||
percent = float(m.group("pct")),
|
||||
ok = int(m.group("ok")),
|
||||
failed = int(m.group("failed")),
|
||||
rate = float(m.group("rate")),
|
||||
eta_sec = float(m.group("eta")),
|
||||
)
|
||||
return ParsedUpdate(stage=STAGE_GENERATING, progress=p)
|
||||
return ParsedUpdate(stage = STAGE_GENERATING, progress = p)
|
||||
|
||||
m = _RE_BATCH.search(msg)
|
||||
if m:
|
||||
return ParsedUpdate(
|
||||
stage=STAGE_BATCH,
|
||||
batch_idx=int(m.group("idx")),
|
||||
batch_total=int(m.group("total")),
|
||||
stage = STAGE_BATCH,
|
||||
batch_idx = int(m.group("idx")),
|
||||
batch_total = int(m.group("total")),
|
||||
)
|
||||
|
||||
if "Model usage summary" in msg:
|
||||
return ParsedUpdate(usage_section_start=True)
|
||||
return ParsedUpdate(usage_section_start = True)
|
||||
|
||||
m = _RE_USAGE_MODEL.search(msg)
|
||||
if m and "|-- model:" in msg:
|
||||
return ParsedUpdate(usage_model=str(m.group("model")).strip())
|
||||
return ParsedUpdate(usage_model = str(m.group("model")).strip())
|
||||
|
||||
m = _RE_USAGE_TOKENS.search(msg)
|
||||
if m:
|
||||
return ParsedUpdate(
|
||||
usage_input_tokens=int(m.group("input")),
|
||||
usage_output_tokens=int(m.group("output")),
|
||||
usage_total_tokens=int(m.group("total")),
|
||||
usage_tps=float(m.group("tps")),
|
||||
usage_input_tokens = int(m.group("input")),
|
||||
usage_output_tokens = int(m.group("output")),
|
||||
usage_total_tokens = int(m.group("total")),
|
||||
usage_tps = float(m.group("tps")),
|
||||
)
|
||||
|
||||
m = _RE_USAGE_REQUESTS.search(msg)
|
||||
if m:
|
||||
return ParsedUpdate(
|
||||
usage_requests_success=int(m.group("success")),
|
||||
usage_requests_failed=int(m.group("failed")),
|
||||
usage_requests_total=int(m.group("total")),
|
||||
usage_rpm=float(m.group("rpm")),
|
||||
usage_requests_success = int(m.group("success")),
|
||||
usage_requests_failed = int(m.group("failed")),
|
||||
usage_requests_total = int(m.group("total")),
|
||||
usage_rpm = float(m.group("rpm")),
|
||||
)
|
||||
|
||||
return None
|
||||
|
|
@ -146,7 +147,10 @@ def apply_update(job: Job, update: ParsedUpdate) -> None:
|
|||
job.stage = update.stage
|
||||
if update.current_column is not None:
|
||||
job.current_column = update.current_column
|
||||
if update.stage == STAGE_GENERATING and update.current_column not in job._seen_generation_columns:
|
||||
if (
|
||||
update.stage == STAGE_GENERATING
|
||||
and update.current_column not in job._seen_generation_columns
|
||||
):
|
||||
job._seen_generation_columns.append(update.current_column)
|
||||
if update.rows is not None:
|
||||
job.rows = update.rows
|
||||
|
|
@ -185,7 +189,7 @@ def apply_update(job: Job, update: ParsedUpdate) -> None:
|
|||
name = update.usage_model.strip().strip("'").strip('"')
|
||||
job._current_usage_model = name
|
||||
if name not in job.model_usage:
|
||||
job.model_usage[name] = ModelUsage(model=name)
|
||||
job.model_usage[name] = ModelUsage(model = name)
|
||||
|
||||
if job._current_usage_model is None:
|
||||
return
|
||||
|
|
@ -227,7 +231,9 @@ def _compute_overall_progress(job: Job, column_progress: Progress) -> Progress:
|
|||
if len(job._column_done) == 0:
|
||||
done = current_done
|
||||
else:
|
||||
sum_done = sum(max(0, min(value, total_rows)) for value in job._column_done.values())
|
||||
sum_done = sum(
|
||||
max(0, min(value, total_rows)) for value in job._column_done.values()
|
||||
)
|
||||
done = int(sum_done / total_columns)
|
||||
|
||||
prev_done = int(job.progress.done or 0)
|
||||
|
|
@ -241,13 +247,13 @@ def _compute_overall_progress(job: Job, column_progress: Progress) -> Progress:
|
|||
percent = prev_percent
|
||||
|
||||
return Progress(
|
||||
done=done,
|
||||
total=total_rows,
|
||||
percent=percent,
|
||||
eta_sec=column_progress.eta_sec,
|
||||
rate=column_progress.rate,
|
||||
ok=column_progress.ok,
|
||||
failed=column_progress.failed,
|
||||
done = done,
|
||||
total = total_rows,
|
||||
percent = percent,
|
||||
eta_sec = column_progress.eta_sec,
|
||||
rate = column_progress.rate,
|
||||
ok = column_progress.ok,
|
||||
failed = column_progress.failed,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -54,9 +54,9 @@ class Job:
|
|||
status: JobStatus = "created"
|
||||
stage: str | None = None
|
||||
current_column: str | None = None
|
||||
progress: Progress = field(default_factory=Progress)
|
||||
column_progress: Progress = field(default_factory=Progress)
|
||||
batch: BatchProgress = field(default_factory=BatchProgress)
|
||||
progress: Progress = field(default_factory = Progress)
|
||||
column_progress: Progress = field(default_factory = Progress)
|
||||
batch: BatchProgress = field(default_factory = BatchProgress)
|
||||
rows: int | None = None
|
||||
cols: int | None = None
|
||||
error: str | None = None
|
||||
|
|
@ -67,10 +67,10 @@ class Job:
|
|||
artifact_path: str | None = None
|
||||
dataset: list[dict[str, Any]] | None = None
|
||||
processor_artifacts: dict[str, Any] | None = None
|
||||
model_usage: dict[str, ModelUsage] = field(default_factory=dict)
|
||||
model_usage: dict[str, ModelUsage] = field(default_factory = dict)
|
||||
progress_columns_total: int | None = None
|
||||
completed_columns: list[str] = field(default_factory=list)
|
||||
completed_columns: list[str] = field(default_factory = list)
|
||||
_current_usage_model: str | None = None
|
||||
_in_usage_summary: bool = False
|
||||
_seen_generation_columns: list[str] = field(default_factory=list)
|
||||
_column_done: dict[str, int] = field(default_factory=dict)
|
||||
_seen_generation_columns: list[str] = field(default_factory = list)
|
||||
_column_done: dict[str, int] = field(default_factory = dict)
|
||||
|
|
|
|||
|
|
@ -51,7 +51,9 @@ def _slugify_run_name(value: str) -> str:
|
|||
return slug[:80].strip("-")
|
||||
|
||||
|
||||
def _build_dataset_name(*, run_name: str | None, job_id: str, artifact_root: Path) -> str:
|
||||
def _build_dataset_name(
|
||||
*, run_name: str | None, job_id: str, artifact_root: Path
|
||||
) -> str:
|
||||
fallback = f"recipe_{job_id}"
|
||||
slug = _slugify_run_name(run_name or "")
|
||||
base_name = f"recipe_{slug}" if slug else fallback
|
||||
|
|
@ -74,16 +76,20 @@ def run_job_process(
|
|||
Sends events to `event_queue`.
|
||||
"""
|
||||
import os
|
||||
os.environ["PYTHONWARNINGS"] = "ignore" # Suppress warnings at C-level before imports
|
||||
|
||||
os.environ["PYTHONWARNINGS"] = (
|
||||
"ignore" # Suppress warnings at C-level before imports
|
||||
)
|
||||
|
||||
import warnings
|
||||
from loggers.config import LogConfig
|
||||
|
||||
if os.getenv("ENVIRONMENT_TYPE", "production") == "production":
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
|
||||
LogConfig.setup_logging(
|
||||
service_name="unsloth-studio-data-worker",
|
||||
env=os.getenv("ENVIRONMENT_TYPE", "production"),
|
||||
service_name = "unsloth-studio-data-worker",
|
||||
env = os.getenv("ENVIRONMENT_TYPE", "production"),
|
||||
)
|
||||
|
||||
event_queue.put({"type": EVENT_JOB_STARTED, "ts": time.time()})
|
||||
|
|
@ -98,16 +104,16 @@ def run_job_process(
|
|||
run_name_raw = run.get("run_name")
|
||||
run_name = run_name_raw if isinstance(run_name_raw, str) else None
|
||||
dataset_name = _build_dataset_name(
|
||||
run_name=run_name,
|
||||
job_id=job_id,
|
||||
artifact_root=_ARTIFACT_ROOT,
|
||||
run_name = run_name,
|
||||
job_id = job_id,
|
||||
artifact_root = _ARTIFACT_ROOT,
|
||||
)
|
||||
merge_batches = bool(run.get("merge_batches"))
|
||||
ensure_dir(_ARTIFACT_ROOT)
|
||||
run_config_raw = run.get("run_config") or {}
|
||||
|
||||
builder = build_config_builder(recipe)
|
||||
designer = create_data_designer(recipe, artifact_path=str(_ARTIFACT_ROOT))
|
||||
designer = create_data_designer(recipe, artifact_path = str(_ARTIFACT_ROOT))
|
||||
|
||||
# DataDesigner configures root logging in DataDesigner.__init__.
|
||||
# Attach queue logger directly to `data_designer` so parser events survive root resets.
|
||||
|
|
@ -123,16 +129,16 @@ def run_job_process(
|
|||
|
||||
execution_type = str(run.get("execution_type") or "full").strip().lower()
|
||||
if execution_type == "preview":
|
||||
results = designer.preview(builder, num_records=rows)
|
||||
results = designer.preview(builder, num_records = rows)
|
||||
analysis = (
|
||||
None
|
||||
if results.analysis is None
|
||||
else to_jsonable(results.analysis.model_dump(mode="json"))
|
||||
else to_jsonable(results.analysis.model_dump(mode = "json"))
|
||||
)
|
||||
dataset = (
|
||||
[]
|
||||
if results.dataset is None
|
||||
else to_preview_jsonable(results.dataset.to_dict(orient="records"))
|
||||
else to_preview_jsonable(results.dataset.to_dict(orient = "records"))
|
||||
)
|
||||
processor_artifacts = (
|
||||
None
|
||||
|
|
@ -151,10 +157,14 @@ def run_job_process(
|
|||
}
|
||||
)
|
||||
else:
|
||||
results = designer.create(builder, num_records=rows, dataset_name=dataset_name)
|
||||
analysis = to_jsonable(results.load_analysis().model_dump(mode="json"))
|
||||
results = designer.create(
|
||||
builder, num_records = rows, dataset_name = dataset_name
|
||||
)
|
||||
analysis = to_jsonable(results.load_analysis().model_dump(mode = "json"))
|
||||
if merge_batches:
|
||||
_merge_batches_to_single_parquet(results.artifact_storage.base_dataset_path)
|
||||
_merge_batches_to_single_parquet(
|
||||
results.artifact_storage.base_dataset_path
|
||||
)
|
||||
artifact_path = str(results.artifact_storage.base_dataset_path)
|
||||
event_queue.put(
|
||||
{
|
||||
|
|
@ -171,7 +181,7 @@ def run_job_process(
|
|||
"type": EVENT_JOB_ERROR,
|
||||
"ts": time.time(),
|
||||
"error": str(exc),
|
||||
"stack": traceback.format_exc(limit=20),
|
||||
"stack": traceback.format_exc(limit = 20),
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -189,12 +199,12 @@ def _merge_batches_to_single_parquet(base_dataset_path: Path) -> None:
|
|||
|
||||
dataframe = read_parquet_dataset(parquet_dir)
|
||||
shutil.rmtree(parquet_dir)
|
||||
parquet_dir.mkdir(parents=True, exist_ok=True)
|
||||
parquet_dir.mkdir(parents = True, exist_ok = True)
|
||||
merged_file = parquet_dir / "batch_00000.parquet"
|
||||
dataframe.to_parquet(merged_file, index=False)
|
||||
dataframe.to_parquet(merged_file, index = False)
|
||||
_rewrite_merged_metadata(
|
||||
base_dataset_path=base_dataset_path,
|
||||
parquet_file=merged_file,
|
||||
base_dataset_path = base_dataset_path,
|
||||
parquet_file = merged_file,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -204,7 +214,7 @@ def _rewrite_merged_metadata(*, base_dataset_path: Path, parquet_file: Path) ->
|
|||
return
|
||||
|
||||
try:
|
||||
metadata = json.loads(metadata_path.read_text(encoding="utf-8"))
|
||||
metadata = json.loads(metadata_path.read_text(encoding = "utf-8"))
|
||||
except (OSError, TypeError, ValueError):
|
||||
return
|
||||
|
||||
|
|
@ -222,8 +232,8 @@ def _rewrite_merged_metadata(*, base_dataset_path: Path, parquet_file: Path) ->
|
|||
|
||||
try:
|
||||
metadata_path.write_text(
|
||||
json.dumps(metadata, indent=2, sort_keys=True),
|
||||
encoding="utf-8",
|
||||
json.dumps(metadata, indent = 2, sort_keys = True),
|
||||
encoding = "utf-8",
|
||||
)
|
||||
except OSError:
|
||||
return
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ from typing import Any
|
|||
|
||||
def _pil_to_preview_payload(image: Any) -> dict[str, Any]:
|
||||
buffer = io.BytesIO()
|
||||
image.convert("RGB").save(buffer, format="JPEG", quality=85)
|
||||
image.convert("RGB").save(buffer, format = "JPEG", quality = 85)
|
||||
return {
|
||||
"type": "image",
|
||||
"mime": "image/jpeg",
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ _OXC_TOOL_DIR = Path(__file__).resolve().parent / "oxc-validator"
|
|||
_OXC_RUNNER_PATH = _OXC_TOOL_DIR / "validate.mjs"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@dataclass(frozen = True)
|
||||
class OxcLocalCallableValidatorSpec:
|
||||
name: str
|
||||
drop: bool
|
||||
|
|
@ -64,7 +64,7 @@ def split_oxc_local_callable_validators(
|
|||
kept_columns.append(column)
|
||||
continue
|
||||
|
||||
maybe_spec = _parse_oxc_spec(column=column)
|
||||
maybe_spec = _parse_oxc_spec(column = column)
|
||||
if maybe_spec is None:
|
||||
kept_columns.append(column)
|
||||
continue
|
||||
|
|
@ -96,14 +96,14 @@ def register_oxc_local_callable_validators(
|
|||
)
|
||||
builder.add_column(
|
||||
ValidationColumnConfig(
|
||||
name=spec.name,
|
||||
drop=spec.drop,
|
||||
target_columns=spec.target_columns,
|
||||
validator_type=ValidatorType.LOCAL_CALLABLE,
|
||||
validator_params=LocalCallableValidatorParams(
|
||||
validation_function=validation_function,
|
||||
name = spec.name,
|
||||
drop = spec.drop,
|
||||
target_columns = spec.target_columns,
|
||||
validator_type = ValidatorType.LOCAL_CALLABLE,
|
||||
validator_params = LocalCallableValidatorParams(
|
||||
validation_function = validation_function,
|
||||
),
|
||||
batch_size=spec.batch_size,
|
||||
batch_size = spec.batch_size,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -132,7 +132,11 @@ def _parse_oxc_spec(
|
|||
|
||||
target_columns_raw = column.get("target_columns")
|
||||
target_columns = (
|
||||
[value.strip() for value in target_columns_raw if isinstance(value, str) and value.strip()]
|
||||
[
|
||||
value.strip()
|
||||
for value in target_columns_raw
|
||||
if isinstance(value, str) and value.strip()
|
||||
]
|
||||
if isinstance(target_columns_raw, list)
|
||||
else []
|
||||
)
|
||||
|
|
@ -144,13 +148,13 @@ def _parse_oxc_spec(
|
|||
drop = bool(column.get("drop") is True)
|
||||
|
||||
return OxcLocalCallableValidatorSpec(
|
||||
name=name,
|
||||
drop=drop,
|
||||
target_columns=target_columns,
|
||||
batch_size=batch_size,
|
||||
code_lang=code_lang,
|
||||
validation_mode=validation_mode,
|
||||
code_shape=code_shape,
|
||||
name = name,
|
||||
drop = drop,
|
||||
target_columns = target_columns,
|
||||
batch_size = batch_size,
|
||||
code_lang = code_lang,
|
||||
validation_mode = validation_mode,
|
||||
code_shape = code_shape,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -172,18 +176,16 @@ def _parse_oxc_validation_marker(fn_name: str) -> tuple[str, str, str]:
|
|||
return "javascript", "syntax", "auto"
|
||||
code_lang = parts[0] if parts[0] in _OXC_LANG_TO_NODE_LANG else "javascript"
|
||||
mode = parts[1] if parts[1] in _OXC_VALIDATION_MODES else "syntax"
|
||||
code_shape = parts[2] if len(parts) >= 3 and parts[2] in _OXC_CODE_SHAPES else "auto"
|
||||
code_shape = (
|
||||
parts[2] if len(parts) >= 3 and parts[2] in _OXC_CODE_SHAPES else "auto"
|
||||
)
|
||||
return code_lang, mode, code_shape
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
@lru_cache(maxsize = 8)
|
||||
def _build_oxc_validation_function(lang: str, validation_mode: str, code_shape: str):
|
||||
node_lang = _OXC_LANG_TO_NODE_LANG.get(lang, "js")
|
||||
mode = (
|
||||
validation_mode
|
||||
if validation_mode in _OXC_VALIDATION_MODES
|
||||
else "syntax"
|
||||
)
|
||||
mode = validation_mode if validation_mode in _OXC_VALIDATION_MODES else "syntax"
|
||||
normalized_code_shape = code_shape if code_shape in _OXC_CODE_SHAPES else "auto"
|
||||
|
||||
def _validator(df):
|
||||
|
|
@ -197,14 +199,17 @@ def _build_oxc_validation_function(lang: str, validation_mode: str, code_shape:
|
|||
code_values = (
|
||||
["" for _ in range(row_count)]
|
||||
if not code_column
|
||||
else ["" if value is None else str(value) for value in df[code_column].tolist()]
|
||||
else [
|
||||
"" if value is None else str(value)
|
||||
for value in df[code_column].tolist()
|
||||
]
|
||||
)
|
||||
|
||||
results = _run_oxc_batch(
|
||||
node_lang=node_lang,
|
||||
validation_mode=mode,
|
||||
code_shape=normalized_code_shape,
|
||||
code_values=code_values,
|
||||
node_lang = node_lang,
|
||||
validation_mode = mode,
|
||||
code_shape = normalized_code_shape,
|
||||
code_values = code_values,
|
||||
)
|
||||
if len(results) != row_count:
|
||||
results = _fallback_results(
|
||||
|
|
@ -213,9 +218,7 @@ def _build_oxc_validation_function(lang: str, validation_mode: str, code_shape:
|
|||
)
|
||||
return pd.DataFrame(results)
|
||||
|
||||
_validator.__name__ = (
|
||||
f"{OXC_VALIDATION_FN_MARKER}_{node_lang}_{mode.replace('+', '_')}_{normalized_code_shape}"
|
||||
)
|
||||
_validator.__name__ = f"{OXC_VALIDATION_FN_MARKER}_{node_lang}_{mode.replace('+', '_')}_{normalized_code_shape}"
|
||||
return _validator
|
||||
|
||||
|
||||
|
|
@ -247,12 +250,12 @@ def _run_oxc_batch(
|
|||
env["TEMP"] = tmp_dir_str
|
||||
proc = subprocess.run(
|
||||
["node", str(_OXC_RUNNER_PATH)],
|
||||
cwd=str(_OXC_TOOL_DIR),
|
||||
input=json.dumps(payload),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
env=env,
|
||||
cwd = str(_OXC_TOOL_DIR),
|
||||
input = json.dumps(payload),
|
||||
text = True,
|
||||
capture_output = True,
|
||||
check = False,
|
||||
env = env,
|
||||
)
|
||||
except (OSError, ValueError) as exc:
|
||||
logger.warning("OXC subprocess launch failed: %s", exc)
|
||||
|
|
@ -298,13 +301,21 @@ def _run_oxc_batch(
|
|||
warning_count_raw = item.get("warning_count")
|
||||
out.append(
|
||||
{
|
||||
"is_valid": bool(is_valid_raw) if isinstance(is_valid_raw, bool) else False,
|
||||
"error_count": int(error_count_raw) if isinstance(error_count_raw, int) else 0,
|
||||
"is_valid": bool(is_valid_raw)
|
||||
if isinstance(is_valid_raw, bool)
|
||||
else False,
|
||||
"error_count": int(error_count_raw)
|
||||
if isinstance(error_count_raw, int)
|
||||
else 0,
|
||||
"error_message": str(message_raw or ""),
|
||||
"severity": str(severity_raw) if isinstance(severity_raw, str) else None,
|
||||
"severity": str(severity_raw)
|
||||
if isinstance(severity_raw, str)
|
||||
else None,
|
||||
"code": str(code_raw) if isinstance(code_raw, str) else None,
|
||||
"labels": labels_raw if isinstance(labels_raw, list) else [],
|
||||
"codeframe": str(codeframe_raw) if isinstance(codeframe_raw, str) else None,
|
||||
"codeframe": str(codeframe_raw)
|
||||
if isinstance(codeframe_raw, str)
|
||||
else None,
|
||||
"warning_count": int(warning_count_raw)
|
||||
if isinstance(warning_count_raw, int)
|
||||
else 0,
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
// 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 { spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ from .local_callable_validators import (
|
|||
register_oxc_local_callable_validators,
|
||||
split_oxc_local_callable_validators,
|
||||
)
|
||||
|
||||
_IMAGE_CONTEXT_PATCHED = False
|
||||
|
||||
|
||||
|
|
@ -21,7 +22,9 @@ def _encode_bytes_to_base64(value: bytes | bytearray) -> str:
|
|||
return base64.b64encode(bytes(value)).decode("utf-8")
|
||||
|
||||
|
||||
def _load_image_file_to_base64(path_value: str, *, base_path: str | None = None) -> str | None:
|
||||
def _load_image_file_to_base64(
|
||||
path_value: str, *, base_path: str | None = None
|
||||
) -> str | None:
|
||||
try:
|
||||
path = Path(path_value)
|
||||
candidates: list[Path] = []
|
||||
|
|
@ -53,7 +56,7 @@ def _pil_image_to_base64(value: Any) -> str | None:
|
|||
image_format = str(getattr(value, "format", "") or "").upper()
|
||||
if image_format not in {"PNG", "JPEG", "JPG", "WEBP", "GIF"}:
|
||||
image_format = "PNG"
|
||||
value.save(buffer, format=image_format)
|
||||
value.save(buffer, format = image_format)
|
||||
return _encode_bytes_to_base64(buffer.getvalue())
|
||||
|
||||
|
||||
|
|
@ -93,7 +96,7 @@ def _normalize_image_context_value(value: Any, *, base_path: str | None = None)
|
|||
|
||||
path_value = value.get("path")
|
||||
if isinstance(path_value, str) and path_value.strip():
|
||||
if as_base64 := _load_image_file_to_base64(path_value, base_path=base_path):
|
||||
if as_base64 := _load_image_file_to_base64(path_value, base_path = base_path):
|
||||
return as_base64
|
||||
return path_value
|
||||
|
||||
|
|
@ -116,8 +119,10 @@ def _apply_data_designer_image_context_patch() -> None:
|
|||
|
||||
original_auto_resolve = ImageContext._auto_resolve_context_value
|
||||
|
||||
def _patched_auto_resolve(self: Any, context_value: Any, base_path: str | None) -> Any:
|
||||
normalized = _normalize_image_context_value(context_value, base_path=base_path)
|
||||
def _patched_auto_resolve(
|
||||
self: Any, context_value: Any, base_path: str | None
|
||||
) -> Any:
|
||||
normalized = _normalize_image_context_value(context_value, base_path = base_path)
|
||||
return original_auto_resolve(self, normalized, base_path)
|
||||
|
||||
ImageContext._auto_resolve_context_value = _patched_auto_resolve
|
||||
|
|
@ -137,12 +142,12 @@ def build_model_providers(recipe: dict[str, Any]):
|
|||
api_key = os.getenv(api_key_env)
|
||||
providers.append(
|
||||
ModelProvider(
|
||||
name=provider["name"],
|
||||
endpoint=provider["endpoint"],
|
||||
provider_type=provider.get("provider_type", "openai"),
|
||||
api_key=api_key,
|
||||
extra_headers=provider.get("extra_headers"),
|
||||
extra_body=provider.get("extra_body"),
|
||||
name = provider["name"],
|
||||
endpoint = provider["endpoint"],
|
||||
provider_type = provider.get("provider_type", "openai"),
|
||||
api_key = api_key,
|
||||
extra_headers = provider.get("extra_headers"),
|
||||
extra_body = provider.get("extra_body"),
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -170,10 +175,10 @@ def build_mcp_providers(
|
|||
args = []
|
||||
providers.append(
|
||||
LocalStdioMCPProvider(
|
||||
name=str(provider.get("name", "")),
|
||||
command=str(provider.get("command", "")),
|
||||
args=[str(value) for value in args],
|
||||
env={str(key): str(value) for key, value in env.items()},
|
||||
name = str(provider.get("name", "")),
|
||||
command = str(provider.get("command", "")),
|
||||
args = [str(value) for value in args],
|
||||
env = {str(key): str(value) for key, value in env.items()},
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
|
@ -185,10 +190,10 @@ def build_mcp_providers(
|
|||
api_key = os.getenv(str(api_key_env))
|
||||
providers.append(
|
||||
MCPProvider(
|
||||
name=str(provider.get("name", "")),
|
||||
endpoint=str(provider.get("endpoint", "")),
|
||||
provider_type=str(provider_type),
|
||||
api_key=str(api_key) if api_key else None,
|
||||
name = str(provider.get("name", "")),
|
||||
endpoint = str(provider.get("endpoint", "")),
|
||||
provider_type = str(provider_type),
|
||||
api_key = str(api_key) if api_key else None,
|
||||
)
|
||||
)
|
||||
return providers
|
||||
|
|
@ -209,8 +214,8 @@ def build_config_builder(recipe: dict[str, Any]):
|
|||
)
|
||||
builder = DataDesignerConfigBuilder.from_config({"data_designer": recipe_core})
|
||||
register_oxc_local_callable_validators(
|
||||
builder=builder,
|
||||
specs=oxc_local_callable_specs,
|
||||
builder = builder,
|
||||
specs = oxc_local_callable_specs,
|
||||
)
|
||||
|
||||
# DataDesignerConfigBuilder.from_config currently skips processors.
|
||||
|
|
@ -223,7 +228,7 @@ def build_config_builder(recipe: dict[str, Any]):
|
|||
continue
|
||||
kwargs = {k: v for k, v in processor.items() if k != "processor_type"}
|
||||
builder.add_processor(
|
||||
processor_type=ProcessorType(processor_type_raw),
|
||||
processor_type = ProcessorType(processor_type_raw),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
|
@ -239,9 +244,9 @@ def create_data_designer(
|
|||
from data_designer.interface.data_designer import DataDesigner
|
||||
|
||||
return DataDesigner(
|
||||
artifact_path=artifact_path,
|
||||
model_providers=build_model_providers(recipe),
|
||||
mcp_providers=build_mcp_providers(recipe),
|
||||
artifact_path = artifact_path,
|
||||
model_providers = build_model_providers(recipe),
|
||||
mcp_providers = build_mcp_providers(recipe),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -257,11 +262,11 @@ def preview_recipe(
|
|||
) -> tuple[list[dict[str, Any]], dict[str, Any] | None, dict[str, Any] | None]:
|
||||
builder = build_config_builder(recipe)
|
||||
designer = create_data_designer(recipe)
|
||||
results = designer.preview(builder, num_records=num_records)
|
||||
results = designer.preview(builder, num_records = num_records)
|
||||
|
||||
dataset: list[dict[str, Any]] = []
|
||||
if results.dataset is not None:
|
||||
raw_rows = results.dataset.to_dict(orient="records")
|
||||
raw_rows = results.dataset.to_dict(orient = "records")
|
||||
dataset = [to_jsonable(row) for row in raw_rows]
|
||||
|
||||
artifacts = (
|
||||
|
|
@ -272,7 +277,7 @@ def preview_recipe(
|
|||
analysis = (
|
||||
None
|
||||
if results.analysis is None
|
||||
else to_jsonable(results.analysis.model_dump(mode="json"))
|
||||
else to_jsonable(results.analysis.model_dump(mode = "json"))
|
||||
)
|
||||
|
||||
return dataset, artifacts, analysis
|
||||
|
|
|
|||
|
|
@ -8,13 +8,14 @@ The default get_export_backend() returns an ExportOrchestrator that
|
|||
delegates to a subprocess. The original ExportBackend runs inside
|
||||
the subprocess and can be imported directly from .export when needed.
|
||||
"""
|
||||
|
||||
from .orchestrator import ExportOrchestrator, get_export_backend
|
||||
|
||||
# Expose ExportOrchestrator as ExportBackend for backward compat
|
||||
ExportBackend = ExportOrchestrator
|
||||
|
||||
__all__ = [
|
||||
'ExportBackend',
|
||||
'ExportOrchestrator',
|
||||
'get_export_backend',
|
||||
"ExportBackend",
|
||||
"ExportOrchestrator",
|
||||
"get_export_backend",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
"""
|
||||
Export backend - handles model exporting in various formats
|
||||
"""
|
||||
|
||||
import glob
|
||||
import json
|
||||
import structlog
|
||||
|
|
@ -50,7 +51,7 @@ def _apply_wsl_sudo_patch():
|
|||
try:
|
||||
import unsloth_zoo.llama_cpp as llama_cpp_module
|
||||
|
||||
def _wsl_do_we_need_sudo(system_type="debian"):
|
||||
def _wsl_do_we_need_sudo(system_type = "debian"):
|
||||
logger.info(
|
||||
"WSL detected — skipping sudo check "
|
||||
"(build deps pre-installed by setup.sh)"
|
||||
|
|
@ -59,16 +60,14 @@ def _apply_wsl_sudo_patch():
|
|||
|
||||
llama_cpp_module.do_we_need_sudo = _wsl_do_we_need_sudo
|
||||
logger.info(
|
||||
"Applied WSL sudo patch to "
|
||||
"unsloth_zoo.llama_cpp.do_we_need_sudo"
|
||||
"Applied WSL sudo patch to " "unsloth_zoo.llama_cpp.do_we_need_sudo"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not apply WSL sudo patch: {e}")
|
||||
|
||||
|
||||
# Model card template
|
||||
MODEL_CARD = \
|
||||
"""---
|
||||
MODEL_CARD = """---
|
||||
base_model: {base_model}
|
||||
tags:
|
||||
- text-generation-inference
|
||||
|
|
@ -92,6 +91,7 @@ This {model_type} model was trained 2x faster with [Unsloth](https://github.com/
|
|||
[<img src="https://raw.githubusercontent.com/unslothai/unsloth/main/images/unsloth%20made%20with%20love.png" width="200"/>](https://github.com/unslothai/unsloth)
|
||||
"""
|
||||
|
||||
|
||||
class ExportBackend:
|
||||
"""Handles model export operations"""
|
||||
|
||||
|
|
@ -130,7 +130,9 @@ class ExportBackend:
|
|||
logger.error(f"Error during memory cleanup: {e}")
|
||||
return False
|
||||
|
||||
def scan_checkpoints(self, outputs_dir: str = str(outputs_root())) -> List[Tuple[str, List[Tuple[str, str]]]]:
|
||||
def scan_checkpoints(
|
||||
self, outputs_dir: str = str(outputs_root())
|
||||
) -> List[Tuple[str, List[Tuple[str, str]]]]:
|
||||
"""
|
||||
Scan outputs folder for training runs and their checkpoints.
|
||||
|
||||
|
|
@ -138,13 +140,16 @@ class ExportBackend:
|
|||
List of tuples: [(model_name, [(display_name, checkpoint_path), ...]), ...]
|
||||
"""
|
||||
from utils.models.checkpoints import scan_checkpoints
|
||||
return scan_checkpoints(outputs_dir=outputs_dir)
|
||||
|
||||
def load_checkpoint(self,
|
||||
checkpoint_path: str,
|
||||
max_seq_length: int = 2048,
|
||||
load_in_4bit: bool = True,
|
||||
trust_remote_code: bool = False) -> Tuple[bool, str]:
|
||||
return scan_checkpoints(outputs_dir = outputs_dir)
|
||||
|
||||
def load_checkpoint(
|
||||
self,
|
||||
checkpoint_path: str,
|
||||
max_seq_length: int = 2048,
|
||||
load_in_4bit: bool = True,
|
||||
trust_remote_code: bool = False,
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
Load a checkpoint for export.
|
||||
|
||||
|
|
@ -174,81 +179,85 @@ class ExportBackend:
|
|||
self.is_vision = not self._audio_type and is_vision_model(model_id)
|
||||
|
||||
# Load model based on type
|
||||
if self._audio_type == 'csm':
|
||||
if self._audio_type == "csm":
|
||||
from unsloth import FastModel
|
||||
from transformers import CsmForConditionalGeneration
|
||||
|
||||
logger.info("Loading as CSM audio model...")
|
||||
model, tokenizer = FastModel.from_pretrained(
|
||||
model_name=checkpoint_path,
|
||||
max_seq_length=max_seq_length,
|
||||
dtype=None,
|
||||
auto_model=CsmForConditionalGeneration,
|
||||
load_in_4bit=False,
|
||||
trust_remote_code=trust_remote_code,
|
||||
model_name = checkpoint_path,
|
||||
max_seq_length = max_seq_length,
|
||||
dtype = None,
|
||||
auto_model = CsmForConditionalGeneration,
|
||||
load_in_4bit = False,
|
||||
trust_remote_code = trust_remote_code,
|
||||
)
|
||||
|
||||
elif self._audio_type == 'whisper':
|
||||
elif self._audio_type == "whisper":
|
||||
from unsloth import FastModel
|
||||
from transformers import WhisperForConditionalGeneration
|
||||
|
||||
logger.info("Loading as Whisper audio model...")
|
||||
model, tokenizer = FastModel.from_pretrained(
|
||||
model_name=checkpoint_path,
|
||||
dtype=None,
|
||||
load_in_4bit=False,
|
||||
auto_model=WhisperForConditionalGeneration,
|
||||
trust_remote_code=trust_remote_code,
|
||||
model_name = checkpoint_path,
|
||||
dtype = None,
|
||||
load_in_4bit = False,
|
||||
auto_model = WhisperForConditionalGeneration,
|
||||
trust_remote_code = trust_remote_code,
|
||||
)
|
||||
|
||||
elif self._audio_type == 'snac':
|
||||
elif self._audio_type == "snac":
|
||||
logger.info("Loading as SNAC (Orpheus) audio model...")
|
||||
model, tokenizer = FastLanguageModel.from_pretrained(
|
||||
model_name=checkpoint_path,
|
||||
max_seq_length=max_seq_length,
|
||||
dtype=None,
|
||||
load_in_4bit=load_in_4bit,
|
||||
trust_remote_code=trust_remote_code,
|
||||
model_name = checkpoint_path,
|
||||
max_seq_length = max_seq_length,
|
||||
dtype = None,
|
||||
load_in_4bit = load_in_4bit,
|
||||
trust_remote_code = trust_remote_code,
|
||||
)
|
||||
|
||||
elif self._audio_type == 'bicodec':
|
||||
elif self._audio_type == "bicodec":
|
||||
from unsloth import FastModel
|
||||
|
||||
logger.info("Loading as BiCodec (Spark-TTS) audio model...")
|
||||
model, tokenizer = FastModel.from_pretrained(
|
||||
model_name=checkpoint_path,
|
||||
max_seq_length=max_seq_length,
|
||||
dtype=torch.float32,
|
||||
load_in_4bit=False,
|
||||
trust_remote_code=trust_remote_code,
|
||||
model_name = checkpoint_path,
|
||||
max_seq_length = max_seq_length,
|
||||
dtype = torch.float32,
|
||||
load_in_4bit = False,
|
||||
trust_remote_code = trust_remote_code,
|
||||
)
|
||||
|
||||
elif self._audio_type == 'dac':
|
||||
elif self._audio_type == "dac":
|
||||
from unsloth import FastModel
|
||||
|
||||
logger.info("Loading as DAC (OuteTTS) audio model...")
|
||||
model, tokenizer = FastModel.from_pretrained(
|
||||
model_name=checkpoint_path,
|
||||
max_seq_length=max_seq_length,
|
||||
load_in_4bit=False,
|
||||
trust_remote_code=trust_remote_code,
|
||||
model_name = checkpoint_path,
|
||||
max_seq_length = max_seq_length,
|
||||
load_in_4bit = False,
|
||||
trust_remote_code = trust_remote_code,
|
||||
)
|
||||
|
||||
elif self.is_vision:
|
||||
logger.info("Loading as vision model...")
|
||||
model, processor = FastVisionModel.from_pretrained(
|
||||
model_name=checkpoint_path,
|
||||
max_seq_length=max_seq_length,
|
||||
dtype=None,
|
||||
load_in_4bit=load_in_4bit,
|
||||
trust_remote_code=trust_remote_code,
|
||||
model_name = checkpoint_path,
|
||||
max_seq_length = max_seq_length,
|
||||
dtype = None,
|
||||
load_in_4bit = load_in_4bit,
|
||||
trust_remote_code = trust_remote_code,
|
||||
)
|
||||
tokenizer = processor # For vision models, processor acts as tokenizer
|
||||
|
||||
else:
|
||||
logger.info("Loading as text model...")
|
||||
model, tokenizer = FastLanguageModel.from_pretrained(
|
||||
model_name=checkpoint_path,
|
||||
max_seq_length=max_seq_length,
|
||||
dtype=None,
|
||||
load_in_4bit=load_in_4bit,
|
||||
trust_remote_code=trust_remote_code,
|
||||
model_name = checkpoint_path,
|
||||
max_seq_length = max_seq_length,
|
||||
dtype = None,
|
||||
load_in_4bit = load_in_4bit,
|
||||
trust_remote_code = trust_remote_code,
|
||||
)
|
||||
|
||||
# Check if PEFT model
|
||||
|
|
@ -273,28 +282,35 @@ class ExportBackend:
|
|||
except Exception as e:
|
||||
logger.error(f"Error loading checkpoint: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
return False, f"Failed to load checkpoint: {str(e)}"
|
||||
|
||||
def _write_export_metadata(self, save_directory: str):
|
||||
"""Write export_metadata.json with base model info for Chat page discovery."""
|
||||
try:
|
||||
base_model = get_base_model_from_lora(self.current_checkpoint) if self.current_checkpoint else None
|
||||
base_model = (
|
||||
get_base_model_from_lora(self.current_checkpoint)
|
||||
if self.current_checkpoint
|
||||
else None
|
||||
)
|
||||
metadata = {"base_model": base_model}
|
||||
metadata_path = os.path.join(save_directory, "export_metadata.json")
|
||||
with open(metadata_path, "w") as f:
|
||||
json.dump(metadata, f, indent=2)
|
||||
json.dump(metadata, f, indent = 2)
|
||||
logger.info(f"Wrote export metadata to {metadata_path}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not write export metadata: {e}")
|
||||
|
||||
def export_merged_model(self,
|
||||
save_directory: str,
|
||||
format_type: str = "16-bit (FP16)",
|
||||
push_to_hub: bool = False,
|
||||
repo_id: Optional[str] = None,
|
||||
hf_token: Optional[str] = None,
|
||||
private: bool = False) -> Tuple[bool, str]:
|
||||
def export_merged_model(
|
||||
self,
|
||||
save_directory: str,
|
||||
format_type: str = "16-bit (FP16)",
|
||||
push_to_hub: bool = False,
|
||||
repo_id: Optional[str] = None,
|
||||
hf_token: Optional[str] = None,
|
||||
private: bool = False,
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
Export merged model (for PEFT models).
|
||||
|
||||
|
|
@ -319,7 +335,7 @@ class ExportBackend:
|
|||
# Determine save method
|
||||
if format_type == "4-bit (FP4)":
|
||||
save_method = "merged_4bit_forced"
|
||||
elif self._audio_type == 'whisper':
|
||||
elif self._audio_type == "whisper":
|
||||
# Whisper uses save_method=None for local 16-bit merged save
|
||||
save_method = None
|
||||
else: # 16-bit (FP16)
|
||||
|
|
@ -332,9 +348,7 @@ class ExportBackend:
|
|||
ensure_dir(Path(save_directory))
|
||||
|
||||
self.current_model.save_pretrained_merged(
|
||||
save_directory,
|
||||
self.current_tokenizer,
|
||||
save_method=save_method
|
||||
save_directory, self.current_tokenizer, save_method = save_method
|
||||
)
|
||||
|
||||
# Write export metadata so the Chat page can identify the base model
|
||||
|
|
@ -344,18 +358,23 @@ class ExportBackend:
|
|||
# Push to hub if requested
|
||||
if push_to_hub:
|
||||
if not repo_id or not hf_token:
|
||||
return False, "Repository ID and Hugging Face token required for Hub upload"
|
||||
return (
|
||||
False,
|
||||
"Repository ID and Hugging Face token required for Hub upload",
|
||||
)
|
||||
|
||||
logger.info(f"Pushing merged model to Hub: {repo_id}")
|
||||
|
||||
# Whisper uses save_method=None for local but "merged_16bit" for hub push
|
||||
hub_save_method = save_method if save_method is not None else "merged_16bit"
|
||||
hub_save_method = (
|
||||
save_method if save_method is not None else "merged_16bit"
|
||||
)
|
||||
self.current_model.push_to_hub_merged(
|
||||
repo_id,
|
||||
self.current_tokenizer,
|
||||
save_method=hub_save_method,
|
||||
token=hf_token,
|
||||
private=private
|
||||
save_method = hub_save_method,
|
||||
token = hf_token,
|
||||
private = private,
|
||||
)
|
||||
logger.info(f"Model pushed successfully to {repo_id}")
|
||||
|
||||
|
|
@ -364,16 +383,19 @@ class ExportBackend:
|
|||
except Exception as e:
|
||||
logger.error(f"Error exporting merged model: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
return False, f"Export failed: {str(e)}"
|
||||
|
||||
def export_base_model(self,
|
||||
save_directory: str,
|
||||
push_to_hub: bool = False,
|
||||
repo_id: Optional[str] = None,
|
||||
hf_token: Optional[str] = None,
|
||||
private: bool = False,
|
||||
base_model_id: Optional[str] = None) -> Tuple[bool, str]:
|
||||
def export_base_model(
|
||||
self,
|
||||
save_directory: str,
|
||||
push_to_hub: bool = False,
|
||||
repo_id: Optional[str] = None,
|
||||
hf_token: Optional[str] = None,
|
||||
private: bool = False,
|
||||
base_model_id: Optional[str] = None,
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
Export base model (for non-PEFT models).
|
||||
|
||||
|
|
@ -384,7 +406,10 @@ class ExportBackend:
|
|||
return False, "No model loaded. Please select a checkpoint first."
|
||||
|
||||
if self.is_peft:
|
||||
return False, "This is a PEFT model. Use 'Merged Model' export type instead."
|
||||
return (
|
||||
False,
|
||||
"This is a PEFT model. Use 'Merged Model' export type instead.",
|
||||
)
|
||||
|
||||
try:
|
||||
# Save locally if requested
|
||||
|
|
@ -403,40 +428,47 @@ class ExportBackend:
|
|||
# Push to hub if requested
|
||||
if push_to_hub:
|
||||
if not repo_id or not hf_token:
|
||||
return False, "Repository ID and Hugging Face token required for Hub upload"
|
||||
return (
|
||||
False,
|
||||
"Repository ID and Hugging Face token required for Hub upload",
|
||||
)
|
||||
|
||||
logger.info(f"Pushing base model to Hub: {repo_id}")
|
||||
|
||||
# Get base model name from request or model config
|
||||
base_model = base_model_id or self.current_model.config._name_or_path or "unknown"
|
||||
base_model = (
|
||||
base_model_id
|
||||
or self.current_model.config._name_or_path
|
||||
or "unknown"
|
||||
)
|
||||
|
||||
# Create repo
|
||||
hf_api = HfApi(token=hf_token)
|
||||
hf_api = HfApi(token = hf_token)
|
||||
repo_id = PushToHubMixin._create_repo(
|
||||
PushToHubMixin,
|
||||
repo_id=repo_id,
|
||||
private=private,
|
||||
token=hf_token,
|
||||
repo_id = repo_id,
|
||||
private = private,
|
||||
token = hf_token,
|
||||
)
|
||||
username = repo_id.split("/")[0]
|
||||
|
||||
# Create and push model card
|
||||
content = MODEL_CARD.format(
|
||||
username=username,
|
||||
base_model=base_model,
|
||||
model_type=self.current_model.config.model_type,
|
||||
method="",
|
||||
extra="unsloth",
|
||||
username = username,
|
||||
base_model = base_model,
|
||||
model_type = self.current_model.config.model_type,
|
||||
method = "",
|
||||
extra = "unsloth",
|
||||
)
|
||||
card = ModelCard(content)
|
||||
card.push_to_hub(repo_id, token=hf_token, commit_message="Unsloth Model Card")
|
||||
card.push_to_hub(
|
||||
repo_id, token = hf_token, commit_message = "Unsloth Model Card"
|
||||
)
|
||||
|
||||
# Upload model files
|
||||
if save_directory:
|
||||
hf_api.upload_folder(
|
||||
folder_path=save_directory,
|
||||
repo_id=repo_id,
|
||||
repo_type="model"
|
||||
folder_path = save_directory, repo_id = repo_id, repo_type = "model"
|
||||
)
|
||||
logger.info(f"Model pushed successfully to {repo_id}")
|
||||
else:
|
||||
|
|
@ -447,16 +479,18 @@ class ExportBackend:
|
|||
except Exception as e:
|
||||
logger.error(f"Error exporting base model: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
return False, f"Export failed: {str(e)}"
|
||||
|
||||
|
||||
def export_gguf(self,
|
||||
save_directory: str,
|
||||
quantization_method: str = "Q4_K_M",
|
||||
push_to_hub: bool = False,
|
||||
repo_id: Optional[str] = None,
|
||||
hf_token: Optional[str] = None) -> Tuple[bool, str]:
|
||||
def export_gguf(
|
||||
self,
|
||||
save_directory: str,
|
||||
quantization_method: str = "Q4_K_M",
|
||||
push_to_hub: bool = False,
|
||||
repo_id: Optional[str] = None,
|
||||
hf_token: Optional[str] = None,
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
Export model in GGUF format.
|
||||
|
||||
|
|
@ -505,17 +539,21 @@ class ExportBackend:
|
|||
self.current_model.save_pretrained_gguf(
|
||||
model_save_path,
|
||||
self.current_tokenizer,
|
||||
quantization_method=quant_method
|
||||
quantization_method = quant_method,
|
||||
)
|
||||
|
||||
# Relocate GGUF artifacts into the export directory.
|
||||
# convert_to_gguf writes .gguf files to cwd (repo root)
|
||||
# because --outfile is a relative path like "model.Q4_K_M.gguf".
|
||||
new_ggufs = set(glob.glob(os.path.join(cwd, "*.gguf"))) - pre_existing_ggufs
|
||||
new_ggufs = (
|
||||
set(glob.glob(os.path.join(cwd, "*.gguf"))) - pre_existing_ggufs
|
||||
)
|
||||
for src in sorted(new_ggufs):
|
||||
dest = os.path.join(abs_save_dir, os.path.basename(src))
|
||||
shutil.move(src, dest)
|
||||
logger.info(f"Relocated GGUF: {os.path.basename(src)} → {abs_save_dir}/")
|
||||
logger.info(
|
||||
f"Relocated GGUF: {os.path.basename(src)} → {abs_save_dir}/"
|
||||
)
|
||||
|
||||
# Flatten any .gguf files from subdirectories into abs_save_dir.
|
||||
# save_pretrained_gguf may create subdirs (e.g. model_gguf/)
|
||||
|
|
@ -528,7 +566,7 @@ class ExportBackend:
|
|||
shutil.move(str(src), dest)
|
||||
logger.info(f"Relocated GGUF: {src.name} → {abs_save_dir}/")
|
||||
# Clean up the subdirectory (intermediate HF files, etc.)
|
||||
shutil.rmtree(str(sub), ignore_errors=True)
|
||||
shutil.rmtree(str(sub), ignore_errors = True)
|
||||
logger.info(f"Cleaned up subdirectory: {sub.name}")
|
||||
|
||||
# Write export metadata so the Chat page can identify the base model
|
||||
|
|
@ -546,15 +584,18 @@ class ExportBackend:
|
|||
# Push to hub if requested
|
||||
if push_to_hub:
|
||||
if not repo_id or not hf_token:
|
||||
return False, "Repository ID and Hugging Face token required for Hub upload"
|
||||
return (
|
||||
False,
|
||||
"Repository ID and Hugging Face token required for Hub upload",
|
||||
)
|
||||
|
||||
logger.info(f"Pushing GGUF model to Hub: {repo_id}")
|
||||
|
||||
self.current_model.push_to_hub_gguf(
|
||||
repo_id,
|
||||
self.current_tokenizer,
|
||||
quantization_method=quant_method,
|
||||
token=hf_token
|
||||
quantization_method = quant_method,
|
||||
token = hf_token,
|
||||
)
|
||||
logger.info(f"GGUF model pushed successfully to {repo_id}")
|
||||
|
||||
|
|
@ -563,15 +604,18 @@ class ExportBackend:
|
|||
except Exception as e:
|
||||
logger.error(f"Error exporting GGUF model: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
return False, f"GGUF export failed: {str(e)}"
|
||||
|
||||
def export_lora_adapter(self,
|
||||
save_directory: str,
|
||||
push_to_hub: bool = False,
|
||||
repo_id: Optional[str] = None,
|
||||
hf_token: Optional[str] = None,
|
||||
private: bool = False) -> Tuple[bool, str]:
|
||||
def export_lora_adapter(
|
||||
self,
|
||||
save_directory: str,
|
||||
push_to_hub: bool = False,
|
||||
repo_id: Optional[str] = None,
|
||||
hf_token: Optional[str] = None,
|
||||
private: bool = False,
|
||||
) -> Tuple[bool, str]:
|
||||
"""
|
||||
Export LoRA adapter only (not merged).
|
||||
|
||||
|
|
@ -598,19 +642,16 @@ class ExportBackend:
|
|||
# Push to hub if requested
|
||||
if push_to_hub:
|
||||
if not repo_id or not hf_token:
|
||||
return False, "Repository ID and Hugging Face token required for Hub upload"
|
||||
return (
|
||||
False,
|
||||
"Repository ID and Hugging Face token required for Hub upload",
|
||||
)
|
||||
|
||||
logger.info(f"Pushing LoRA adapter to Hub: {repo_id}")
|
||||
|
||||
self.current_model.push_to_hub(
|
||||
repo_id,
|
||||
token=hf_token,
|
||||
private=private
|
||||
)
|
||||
self.current_model.push_to_hub(repo_id, token = hf_token, private = private)
|
||||
self.current_tokenizer.push_to_hub(
|
||||
repo_id,
|
||||
token=hf_token,
|
||||
private=private
|
||||
repo_id, token = hf_token, private = private
|
||||
)
|
||||
logger.info(f"Adapter pushed successfully to {repo_id}")
|
||||
|
||||
|
|
@ -619,6 +660,7 @@ class ExportBackend:
|
|||
except Exception as e:
|
||||
logger.error(f"Error exporting LoRA adapter: {e}")
|
||||
import traceback
|
||||
|
||||
logger.error(traceback.format_exc())
|
||||
return False, f"Adapter export failed: {str(e)}"
|
||||
|
||||
|
|
@ -626,6 +668,7 @@ class ExportBackend:
|
|||
# Global export backend instance
|
||||
_export_backend = None
|
||||
|
||||
|
||||
def get_export_backend() -> ExportBackend:
|
||||
"""Get or create the global export backend instance"""
|
||||
global _export_backend
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ the old subprocess is killed and a new one is spawned with the correct version.
|
|||
|
||||
Pattern follows core/inference/orchestrator.py.
|
||||
"""
|
||||
|
||||
import atexit
|
||||
import structlog
|
||||
from loggers import get_logger
|
||||
|
|
@ -65,13 +66,13 @@ class ExportOrchestrator:
|
|||
self._resp_queue = _CTX.Queue()
|
||||
|
||||
self._proc = _CTX.Process(
|
||||
target=run_export_process,
|
||||
kwargs={
|
||||
target = run_export_process,
|
||||
kwargs = {
|
||||
"cmd_queue": self._cmd_queue,
|
||||
"resp_queue": self._resp_queue,
|
||||
"config": config,
|
||||
},
|
||||
daemon=True,
|
||||
daemon = True,
|
||||
)
|
||||
self._proc.start()
|
||||
logger.info("Export subprocess started (pid=%s)", self._proc.pid)
|
||||
|
|
@ -93,7 +94,7 @@ class ExportOrchestrator:
|
|||
|
||||
# 3. Wait for graceful shutdown
|
||||
try:
|
||||
self._proc.join(timeout=timeout)
|
||||
self._proc.join(timeout = timeout)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
@ -102,14 +103,14 @@ class ExportOrchestrator:
|
|||
logger.warning("Export subprocess did not exit gracefully, terminating")
|
||||
try:
|
||||
self._proc.terminate()
|
||||
self._proc.join(timeout=5)
|
||||
self._proc.join(timeout = 5)
|
||||
except Exception:
|
||||
pass
|
||||
if self._proc is not None and self._proc.is_alive():
|
||||
logger.warning("Subprocess still alive after terminate, killing")
|
||||
try:
|
||||
self._proc.kill()
|
||||
self._proc.join(timeout=3)
|
||||
self._proc.join(timeout = 3)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
@ -120,7 +121,7 @@ class ExportOrchestrator:
|
|||
|
||||
def _cleanup(self):
|
||||
"""atexit handler."""
|
||||
self._shutdown_subprocess(timeout=5.0)
|
||||
self._shutdown_subprocess(timeout = 5.0)
|
||||
|
||||
def _ensure_subprocess_alive(self) -> bool:
|
||||
"""Check if subprocess is alive."""
|
||||
|
|
@ -144,15 +145,13 @@ class ExportOrchestrator:
|
|||
if self._resp_queue is None:
|
||||
return None
|
||||
try:
|
||||
return self._resp_queue.get(timeout=timeout)
|
||||
return self._resp_queue.get(timeout = timeout)
|
||||
except queue.Empty:
|
||||
return None
|
||||
except (EOFError, OSError, ValueError):
|
||||
return None
|
||||
|
||||
def _wait_response(
|
||||
self, expected_type: str, timeout: float = 3600.0
|
||||
) -> dict:
|
||||
def _wait_response(self, expected_type: str, timeout: float = 3600.0) -> dict:
|
||||
"""Block until a response of the expected type arrives.
|
||||
|
||||
Export operations can take a very long time — GGUF conversion for
|
||||
|
|
@ -163,7 +162,7 @@ class ExportOrchestrator:
|
|||
|
||||
while time.monotonic() < deadline:
|
||||
remaining = max(0.1, deadline - time.monotonic())
|
||||
resp = self._read_resp(timeout=min(remaining, 2.0))
|
||||
resp = self._read_resp(timeout = min(remaining, 2.0))
|
||||
|
||||
if resp is None:
|
||||
# Check subprocess health
|
||||
|
|
@ -187,7 +186,8 @@ class ExportOrchestrator:
|
|||
# Other response types during wait — skip
|
||||
logger.debug(
|
||||
"Skipping response type '%s' while waiting for '%s'",
|
||||
rtype, expected_type,
|
||||
rtype,
|
||||
expected_type,
|
||||
)
|
||||
|
||||
raise RuntimeError(
|
||||
|
|
@ -233,15 +233,15 @@ class ExportOrchestrator:
|
|||
if self._ensure_subprocess_alive():
|
||||
self._shutdown_subprocess()
|
||||
elif self._proc is not None:
|
||||
self._shutdown_subprocess(timeout=2)
|
||||
self._shutdown_subprocess(timeout = 2)
|
||||
|
||||
logger.info("Spawning fresh export subprocess for '%s'", checkpoint_path)
|
||||
self._spawn_subprocess(sub_config)
|
||||
|
||||
try:
|
||||
resp = self._wait_response("loaded", timeout=300)
|
||||
resp = self._wait_response("loaded", timeout = 300)
|
||||
except RuntimeError as exc:
|
||||
self._shutdown_subprocess(timeout=5)
|
||||
self._shutdown_subprocess(timeout = 5)
|
||||
self.current_checkpoint = None
|
||||
self.is_vision = False
|
||||
self.is_peft = False
|
||||
|
|
@ -271,14 +271,17 @@ class ExportOrchestrator:
|
|||
private: bool = False,
|
||||
) -> Tuple[bool, str]:
|
||||
"""Export merged PEFT model."""
|
||||
return self._run_export("merged", {
|
||||
"save_directory": save_directory,
|
||||
"format_type": format_type,
|
||||
"push_to_hub": push_to_hub,
|
||||
"repo_id": repo_id,
|
||||
"hf_token": hf_token,
|
||||
"private": private,
|
||||
})
|
||||
return self._run_export(
|
||||
"merged",
|
||||
{
|
||||
"save_directory": save_directory,
|
||||
"format_type": format_type,
|
||||
"push_to_hub": push_to_hub,
|
||||
"repo_id": repo_id,
|
||||
"hf_token": hf_token,
|
||||
"private": private,
|
||||
},
|
||||
)
|
||||
|
||||
def export_base_model(
|
||||
self,
|
||||
|
|
@ -290,14 +293,17 @@ class ExportOrchestrator:
|
|||
base_model_id: Optional[str] = None,
|
||||
) -> Tuple[bool, str]:
|
||||
"""Export base model (non-PEFT)."""
|
||||
return self._run_export("base", {
|
||||
"save_directory": save_directory,
|
||||
"push_to_hub": push_to_hub,
|
||||
"repo_id": repo_id,
|
||||
"hf_token": hf_token,
|
||||
"private": private,
|
||||
"base_model_id": base_model_id,
|
||||
})
|
||||
return self._run_export(
|
||||
"base",
|
||||
{
|
||||
"save_directory": save_directory,
|
||||
"push_to_hub": push_to_hub,
|
||||
"repo_id": repo_id,
|
||||
"hf_token": hf_token,
|
||||
"private": private,
|
||||
"base_model_id": base_model_id,
|
||||
},
|
||||
)
|
||||
|
||||
def export_gguf(
|
||||
self,
|
||||
|
|
@ -308,13 +314,16 @@ class ExportOrchestrator:
|
|||
hf_token: Optional[str] = None,
|
||||
) -> Tuple[bool, str]:
|
||||
"""Export model in GGUF format."""
|
||||
return self._run_export("gguf", {
|
||||
"save_directory": save_directory,
|
||||
"quantization_method": quantization_method,
|
||||
"push_to_hub": push_to_hub,
|
||||
"repo_id": repo_id,
|
||||
"hf_token": hf_token,
|
||||
})
|
||||
return self._run_export(
|
||||
"gguf",
|
||||
{
|
||||
"save_directory": save_directory,
|
||||
"quantization_method": quantization_method,
|
||||
"push_to_hub": push_to_hub,
|
||||
"repo_id": repo_id,
|
||||
"hf_token": hf_token,
|
||||
},
|
||||
)
|
||||
|
||||
def export_lora_adapter(
|
||||
self,
|
||||
|
|
@ -325,13 +334,16 @@ class ExportOrchestrator:
|
|||
private: bool = False,
|
||||
) -> Tuple[bool, str]:
|
||||
"""Export LoRA adapter only."""
|
||||
return self._run_export("lora", {
|
||||
"save_directory": save_directory,
|
||||
"push_to_hub": push_to_hub,
|
||||
"repo_id": repo_id,
|
||||
"hf_token": hf_token,
|
||||
"private": private,
|
||||
})
|
||||
return self._run_export(
|
||||
"lora",
|
||||
{
|
||||
"save_directory": save_directory,
|
||||
"push_to_hub": push_to_hub,
|
||||
"repo_id": repo_id,
|
||||
"hf_token": hf_token,
|
||||
"private": private,
|
||||
},
|
||||
)
|
||||
|
||||
def _run_export(self, export_type: str, params: dict) -> Tuple[bool, str]:
|
||||
"""Send an export command to the subprocess and wait for result."""
|
||||
|
|
@ -344,7 +356,7 @@ class ExportOrchestrator:
|
|||
self._send_cmd(cmd)
|
||||
resp = self._wait_response(
|
||||
f"export_{export_type}_done",
|
||||
timeout=3600, # GGUF for 30B+ models can take 30+ min
|
||||
timeout = 3600, # GGUF for 30B+ models can take 30+ min
|
||||
)
|
||||
return resp.get("success", False), resp.get("message", "")
|
||||
except RuntimeError as exc:
|
||||
|
|
@ -361,7 +373,7 @@ class ExportOrchestrator:
|
|||
|
||||
try:
|
||||
self._send_cmd({"type": "cleanup"})
|
||||
resp = self._wait_response("cleanup_done", timeout=30)
|
||||
resp = self._wait_response("cleanup_done", timeout = 30)
|
||||
success = resp.get("success", False)
|
||||
except RuntimeError:
|
||||
success = False
|
||||
|
|
@ -379,7 +391,8 @@ class ExportOrchestrator:
|
|||
) -> List[Tuple[str, list]]:
|
||||
"""Scan for checkpoints — no ML imports needed, runs locally."""
|
||||
from utils.models.checkpoints import scan_checkpoints
|
||||
return scan_checkpoints(outputs_dir=outputs_dir)
|
||||
|
||||
return scan_checkpoints(outputs_dir = outputs_dir)
|
||||
|
||||
|
||||
# ========== GLOBAL INSTANCE ==========
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ shutdown) via mp.Queue.
|
|||
|
||||
Pattern follows core/inference/worker.py and core/training/worker.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import structlog
|
||||
|
|
@ -43,25 +44,45 @@ def _activate_transformers_version(model_name: str) -> None:
|
|||
|
||||
resolved = _resolve_base_model(model_name)
|
||||
if needs_transformers_5(resolved):
|
||||
venv_t5 = os.path.join(os.path.expanduser("~"), ".unsloth", "studio", ".venv_t5")
|
||||
venv_t5 = os.path.join(
|
||||
os.path.expanduser("~"), ".unsloth", "studio", ".venv_t5"
|
||||
)
|
||||
if os.path.isdir(venv_t5):
|
||||
sys.path.insert(0, venv_t5)
|
||||
logger.info("Activated transformers 5.x from %s", venv_t5)
|
||||
else:
|
||||
|
||||
# Fallback: pip install at runtime (slower, ~10-15s)
|
||||
logger.warning(".venv_t5 not found at %s — installing at runtime", venv_t5)
|
||||
import subprocess as sp
|
||||
os.makedirs(venv_t5, exist_ok=True)
|
||||
|
||||
os.makedirs(venv_t5, exist_ok = True)
|
||||
r1 = sp.run(
|
||||
[sys.executable, "-m", "pip", "install", "--target", venv_t5,
|
||||
"--no-deps", "transformers==5.2.0"],
|
||||
stdout=sp.PIPE, stderr=sp.STDOUT,
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"--target",
|
||||
venv_t5,
|
||||
"--no-deps",
|
||||
"transformers==5.2.0",
|
||||
],
|
||||
stdout = sp.PIPE,
|
||||
stderr = sp.STDOUT,
|
||||
)
|
||||
r2 = sp.run(
|
||||
[sys.executable, "-m", "pip", "install", "--target", venv_t5,
|
||||
"--no-deps", "huggingface_hub==1.3.0"],
|
||||
stdout=sp.PIPE, stderr=sp.STDOUT,
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"--target",
|
||||
venv_t5,
|
||||
"--no-deps",
|
||||
"huggingface_hub==1.3.0",
|
||||
],
|
||||
stdout = sp.PIPE,
|
||||
stderr = sp.STDOUT,
|
||||
)
|
||||
if r1.returncode != 0 or r2.returncode != 0:
|
||||
raise RuntimeError(
|
||||
|
|
@ -92,37 +113,46 @@ def _handle_load(backend, cmd: dict, resp_queue: Any) -> None:
|
|||
trust_remote_code = cmd.get("trust_remote_code", False)
|
||||
|
||||
try:
|
||||
_send_response(resp_queue, {
|
||||
"type": "status",
|
||||
"message": f"Loading checkpoint: {checkpoint_path}",
|
||||
"ts": time.time(),
|
||||
})
|
||||
|
||||
success, message = backend.load_checkpoint(
|
||||
checkpoint_path=checkpoint_path,
|
||||
max_seq_length=max_seq_length,
|
||||
load_in_4bit=load_in_4bit,
|
||||
trust_remote_code=trust_remote_code,
|
||||
_send_response(
|
||||
resp_queue,
|
||||
{
|
||||
"type": "status",
|
||||
"message": f"Loading checkpoint: {checkpoint_path}",
|
||||
"ts": time.time(),
|
||||
},
|
||||
)
|
||||
|
||||
_send_response(resp_queue, {
|
||||
"type": "loaded",
|
||||
"success": success,
|
||||
"message": message,
|
||||
"checkpoint": checkpoint_path if success else None,
|
||||
"is_vision": backend.is_vision if success else False,
|
||||
"is_peft": backend.is_peft if success else False,
|
||||
"ts": time.time(),
|
||||
})
|
||||
success, message = backend.load_checkpoint(
|
||||
checkpoint_path = checkpoint_path,
|
||||
max_seq_length = max_seq_length,
|
||||
load_in_4bit = load_in_4bit,
|
||||
trust_remote_code = trust_remote_code,
|
||||
)
|
||||
|
||||
_send_response(
|
||||
resp_queue,
|
||||
{
|
||||
"type": "loaded",
|
||||
"success": success,
|
||||
"message": message,
|
||||
"checkpoint": checkpoint_path if success else None,
|
||||
"is_vision": backend.is_vision if success else False,
|
||||
"is_peft": backend.is_peft if success else False,
|
||||
"ts": time.time(),
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
_send_response(resp_queue, {
|
||||
"type": "loaded",
|
||||
"success": False,
|
||||
"message": str(exc),
|
||||
"stack": traceback.format_exc(limit=20),
|
||||
"ts": time.time(),
|
||||
})
|
||||
_send_response(
|
||||
resp_queue,
|
||||
{
|
||||
"type": "loaded",
|
||||
"success": False,
|
||||
"message": str(exc),
|
||||
"stack": traceback.format_exc(limit = 20),
|
||||
"ts": time.time(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _handle_export(backend, cmd: dict, resp_queue: Any) -> None:
|
||||
|
|
@ -133,74 +163,86 @@ def _handle_export(backend, cmd: dict, resp_queue: Any) -> None:
|
|||
try:
|
||||
if export_type == "merged":
|
||||
success, message = backend.export_merged_model(
|
||||
save_directory=cmd.get("save_directory", ""),
|
||||
format_type=cmd.get("format_type", "16-bit (FP16)"),
|
||||
push_to_hub=cmd.get("push_to_hub", False),
|
||||
repo_id=cmd.get("repo_id"),
|
||||
hf_token=cmd.get("hf_token"),
|
||||
private=cmd.get("private", False),
|
||||
save_directory = cmd.get("save_directory", ""),
|
||||
format_type = cmd.get("format_type", "16-bit (FP16)"),
|
||||
push_to_hub = cmd.get("push_to_hub", False),
|
||||
repo_id = cmd.get("repo_id"),
|
||||
hf_token = cmd.get("hf_token"),
|
||||
private = cmd.get("private", False),
|
||||
)
|
||||
elif export_type == "base":
|
||||
success, message = backend.export_base_model(
|
||||
save_directory=cmd.get("save_directory", ""),
|
||||
push_to_hub=cmd.get("push_to_hub", False),
|
||||
repo_id=cmd.get("repo_id"),
|
||||
hf_token=cmd.get("hf_token"),
|
||||
private=cmd.get("private", False),
|
||||
base_model_id=cmd.get("base_model_id"),
|
||||
save_directory = cmd.get("save_directory", ""),
|
||||
push_to_hub = cmd.get("push_to_hub", False),
|
||||
repo_id = cmd.get("repo_id"),
|
||||
hf_token = cmd.get("hf_token"),
|
||||
private = cmd.get("private", False),
|
||||
base_model_id = cmd.get("base_model_id"),
|
||||
)
|
||||
elif export_type == "gguf":
|
||||
success, message = backend.export_gguf(
|
||||
save_directory=cmd.get("save_directory", ""),
|
||||
quantization_method=cmd.get("quantization_method", "Q4_K_M"),
|
||||
push_to_hub=cmd.get("push_to_hub", False),
|
||||
repo_id=cmd.get("repo_id"),
|
||||
hf_token=cmd.get("hf_token"),
|
||||
save_directory = cmd.get("save_directory", ""),
|
||||
quantization_method = cmd.get("quantization_method", "Q4_K_M"),
|
||||
push_to_hub = cmd.get("push_to_hub", False),
|
||||
repo_id = cmd.get("repo_id"),
|
||||
hf_token = cmd.get("hf_token"),
|
||||
)
|
||||
elif export_type == "lora":
|
||||
success, message = backend.export_lora_adapter(
|
||||
save_directory=cmd.get("save_directory", ""),
|
||||
push_to_hub=cmd.get("push_to_hub", False),
|
||||
repo_id=cmd.get("repo_id"),
|
||||
hf_token=cmd.get("hf_token"),
|
||||
private=cmd.get("private", False),
|
||||
save_directory = cmd.get("save_directory", ""),
|
||||
push_to_hub = cmd.get("push_to_hub", False),
|
||||
repo_id = cmd.get("repo_id"),
|
||||
hf_token = cmd.get("hf_token"),
|
||||
private = cmd.get("private", False),
|
||||
)
|
||||
else:
|
||||
success, message = False, f"Unknown export type: {export_type}"
|
||||
|
||||
_send_response(resp_queue, {
|
||||
"type": response_type,
|
||||
"success": success,
|
||||
"message": message,
|
||||
"ts": time.time(),
|
||||
})
|
||||
_send_response(
|
||||
resp_queue,
|
||||
{
|
||||
"type": response_type,
|
||||
"success": success,
|
||||
"message": message,
|
||||
"ts": time.time(),
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
_send_response(resp_queue, {
|
||||
"type": response_type,
|
||||
"success": False,
|
||||
"message": str(exc),
|
||||
"stack": traceback.format_exc(limit=20),
|
||||
"ts": time.time(),
|
||||
})
|
||||
_send_response(
|
||||
resp_queue,
|
||||
{
|
||||
"type": response_type,
|
||||
"success": False,
|
||||
"message": str(exc),
|
||||
"stack": traceback.format_exc(limit = 20),
|
||||
"ts": time.time(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _handle_cleanup(backend, resp_queue: Any) -> None:
|
||||
"""Handle a cleanup command."""
|
||||
try:
|
||||
success = backend.cleanup_memory()
|
||||
_send_response(resp_queue, {
|
||||
"type": "cleanup_done",
|
||||
"success": success,
|
||||
"ts": time.time(),
|
||||
})
|
||||
_send_response(
|
||||
resp_queue,
|
||||
{
|
||||
"type": "cleanup_done",
|
||||
"success": success,
|
||||
"ts": time.time(),
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
_send_response(resp_queue, {
|
||||
"type": "cleanup_done",
|
||||
"success": False,
|
||||
"message": str(exc),
|
||||
"ts": time.time(),
|
||||
})
|
||||
_send_response(
|
||||
resp_queue,
|
||||
{
|
||||
"type": "cleanup_done",
|
||||
"success": False,
|
||||
"message": str(exc),
|
||||
"ts": time.time(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def run_export_process(
|
||||
|
|
@ -219,16 +261,19 @@ def run_export_process(
|
|||
import queue as _queue
|
||||
|
||||
os.environ["TOKENIZERS_PARALLELISM"] = "false"
|
||||
os.environ["PYTHONWARNINGS"] = "ignore" # Suppress warnings at C-level before imports
|
||||
os.environ["PYTHONWARNINGS"] = (
|
||||
"ignore" # Suppress warnings at C-level before imports
|
||||
)
|
||||
|
||||
import warnings
|
||||
from loggers.config import LogConfig
|
||||
|
||||
if os.getenv("ENVIRONMENT_TYPE", "production") == "production":
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
LogConfig.setup_logging(
|
||||
service_name="unsloth-studio-export-worker",
|
||||
env=os.getenv("ENVIRONMENT_TYPE", "production"),
|
||||
service_name = "unsloth-studio-export-worker",
|
||||
env = os.getenv("ENVIRONMENT_TYPE", "production"),
|
||||
)
|
||||
|
||||
checkpoint_path = config["checkpoint_path"]
|
||||
|
|
@ -237,18 +282,22 @@ def run_export_process(
|
|||
try:
|
||||
_activate_transformers_version(checkpoint_path)
|
||||
except Exception as exc:
|
||||
_send_response(resp_queue, {
|
||||
"type": "error",
|
||||
"error": f"Failed to activate transformers version: {exc}",
|
||||
"stack": traceback.format_exc(limit=20),
|
||||
"ts": time.time(),
|
||||
})
|
||||
_send_response(
|
||||
resp_queue,
|
||||
{
|
||||
"type": "error",
|
||||
"error": f"Failed to activate transformers version: {exc}",
|
||||
"stack": traceback.format_exc(limit = 20),
|
||||
"ts": time.time(),
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
# ── 1b. On Windows, check Triton availability (must be before import torch) ──
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
import triton # noqa: F401
|
||||
|
||||
logger.info("Triton available — torch.compile enabled")
|
||||
except ImportError:
|
||||
os.environ["TORCHDYNAMO_DISABLE"] = "1"
|
||||
|
|
@ -259,11 +308,14 @@ def run_export_process(
|
|||
|
||||
# ── 2. Import ML libraries (fresh in this clean process) ──
|
||||
try:
|
||||
_send_response(resp_queue, {
|
||||
"type": "status",
|
||||
"message": "Importing ML libraries...",
|
||||
"ts": time.time(),
|
||||
})
|
||||
_send_response(
|
||||
resp_queue,
|
||||
{
|
||||
"type": "status",
|
||||
"message": "Importing ML libraries...",
|
||||
"ts": time.time(),
|
||||
},
|
||||
)
|
||||
|
||||
backend_path = str(Path(__file__).resolve().parent.parent.parent)
|
||||
if backend_path not in sys.path:
|
||||
|
|
@ -272,15 +324,21 @@ def run_export_process(
|
|||
from core.export.export import ExportBackend
|
||||
|
||||
import transformers
|
||||
logger.info("Export subprocess loaded transformers %s", transformers.__version__)
|
||||
|
||||
logger.info(
|
||||
"Export subprocess loaded transformers %s", transformers.__version__
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
_send_response(resp_queue, {
|
||||
"type": "error",
|
||||
"error": f"Failed to import ML libraries: {exc}",
|
||||
"stack": traceback.format_exc(limit=20),
|
||||
"ts": time.time(),
|
||||
})
|
||||
_send_response(
|
||||
resp_queue,
|
||||
{
|
||||
"type": "error",
|
||||
"error": f"Failed to import ML libraries: {exc}",
|
||||
"stack": traceback.format_exc(limit = 20),
|
||||
"ts": time.time(),
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
# ── 3. Create export backend and load initial checkpoint ──
|
||||
|
|
@ -290,12 +348,15 @@ def run_export_process(
|
|||
_handle_load(backend, config, resp_queue)
|
||||
|
||||
except Exception as exc:
|
||||
_send_response(resp_queue, {
|
||||
"type": "error",
|
||||
"error": f"Failed to initialize export backend: {exc}",
|
||||
"stack": traceback.format_exc(limit=20),
|
||||
"ts": time.time(),
|
||||
})
|
||||
_send_response(
|
||||
resp_queue,
|
||||
{
|
||||
"type": "error",
|
||||
"error": f"Failed to initialize export backend: {exc}",
|
||||
"stack": traceback.format_exc(limit = 20),
|
||||
"ts": time.time(),
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
# ── 4. Command loop — process commands until shutdown ──
|
||||
|
|
@ -303,7 +364,7 @@ def run_export_process(
|
|||
|
||||
while True:
|
||||
try:
|
||||
cmd = cmd_queue.get(timeout=1.0)
|
||||
cmd = cmd_queue.get(timeout = 1.0)
|
||||
except _queue.Empty:
|
||||
continue
|
||||
except (EOFError, OSError):
|
||||
|
|
@ -329,13 +390,16 @@ def run_export_process(
|
|||
_handle_cleanup(backend, resp_queue)
|
||||
|
||||
elif cmd_type == "status":
|
||||
_send_response(resp_queue, {
|
||||
"type": "status_response",
|
||||
"checkpoint": backend.current_checkpoint,
|
||||
"is_vision": backend.is_vision,
|
||||
"is_peft": backend.is_peft,
|
||||
"ts": time.time(),
|
||||
})
|
||||
_send_response(
|
||||
resp_queue,
|
||||
{
|
||||
"type": "status_response",
|
||||
"checkpoint": backend.current_checkpoint,
|
||||
"is_vision": backend.is_vision,
|
||||
"is_peft": backend.is_peft,
|
||||
"ts": time.time(),
|
||||
},
|
||||
)
|
||||
|
||||
elif cmd_type == "shutdown":
|
||||
logger.info("Shutdown command received, cleaning up and exiting")
|
||||
|
|
@ -343,25 +407,36 @@ def run_export_process(
|
|||
backend.cleanup_memory()
|
||||
except Exception:
|
||||
pass
|
||||
_send_response(resp_queue, {
|
||||
"type": "shutdown_ack",
|
||||
"ts": time.time(),
|
||||
})
|
||||
_send_response(
|
||||
resp_queue,
|
||||
{
|
||||
"type": "shutdown_ack",
|
||||
"ts": time.time(),
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
else:
|
||||
logger.warning("Unknown command type: %s", cmd_type)
|
||||
_send_response(resp_queue, {
|
||||
"type": "error",
|
||||
"error": f"Unknown command type: {cmd_type}",
|
||||
"ts": time.time(),
|
||||
})
|
||||
_send_response(
|
||||
resp_queue,
|
||||
{
|
||||
"type": "error",
|
||||
"error": f"Unknown command type: {cmd_type}",
|
||||
"ts": time.time(),
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("Error handling command '%s': %s", cmd_type, exc, exc_info=True)
|
||||
_send_response(resp_queue, {
|
||||
"type": "error",
|
||||
"error": f"Command '{cmd_type}' failed: {exc}",
|
||||
"stack": traceback.format_exc(limit=20),
|
||||
"ts": time.time(),
|
||||
})
|
||||
logger.error(
|
||||
"Error handling command '%s': %s", cmd_type, exc, exc_info = True
|
||||
)
|
||||
_send_response(
|
||||
resp_queue,
|
||||
{
|
||||
"type": "error",
|
||||
"error": f"Command '{cmd_type}' failed: {exc}",
|
||||
"stack": traceback.format_exc(limit = 20),
|
||||
"ts": time.time(),
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ The default get_inference_backend() returns an InferenceOrchestrator that
|
|||
delegates to a subprocess. The original InferenceBackend runs inside
|
||||
the subprocess and can be imported directly from .inference when needed.
|
||||
"""
|
||||
|
||||
from .orchestrator import InferenceOrchestrator, get_inference_backend
|
||||
from .llama_cpp import LlamaCppBackend
|
||||
|
||||
|
|
@ -15,8 +16,8 @@ from .llama_cpp import LlamaCppBackend
|
|||
InferenceBackend = InferenceOrchestrator
|
||||
|
||||
__all__ = [
|
||||
'InferenceBackend',
|
||||
'InferenceOrchestrator',
|
||||
'get_inference_backend',
|
||||
'LlamaCppBackend',
|
||||
"InferenceBackend",
|
||||
"InferenceOrchestrator",
|
||||
"get_inference_backend",
|
||||
"LlamaCppBackend",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
Audio codec loading and decoding for TTS inference.
|
||||
Supports: SNAC (Orpheus), CSM (Sesame), BiCodec (Spark), DAC (OuteTTS)
|
||||
"""
|
||||
|
||||
import io
|
||||
import re
|
||||
import wave
|
||||
|
|
@ -45,7 +46,12 @@ class AudioCodecManager:
|
|||
self._bicodec_repo_path = None
|
||||
self._dac_audio_codec = None
|
||||
|
||||
def load_codec(self, audio_type: str, device: str = "cuda", model_repo_path: Optional[str] = None) -> None:
|
||||
def load_codec(
|
||||
self,
|
||||
audio_type: str,
|
||||
device: str = "cuda",
|
||||
model_repo_path: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Load the appropriate codec for the given audio type."""
|
||||
if audio_type == "snac":
|
||||
self._load_snac(device)
|
||||
|
|
@ -64,7 +70,10 @@ class AudioCodecManager:
|
|||
if self._snac_model is not None:
|
||||
return
|
||||
from snac import SNAC
|
||||
self._snac_model = SNAC.from_pretrained("hubertsiuzdak/snac_24khz").to(device).eval()
|
||||
|
||||
self._snac_model = (
|
||||
SNAC.from_pretrained("hubertsiuzdak/snac_24khz").to(device).eval()
|
||||
)
|
||||
logger.info("Loaded SNAC codec (24kHz)")
|
||||
|
||||
def _load_bicodec(self, device: str, model_repo_path: Optional[str] = None) -> None:
|
||||
|
|
@ -76,13 +85,22 @@ class AudioCodecManager:
|
|||
|
||||
# Clone SparkAudio/Spark-TTS GitHub repo for the sparktts Python package
|
||||
# (same approach as training — the HF model repos don't contain the package)
|
||||
spark_code_dir = os.path.join(os.path.dirname(model_repo_path or "."), "Spark-TTS")
|
||||
spark_code_dir = os.path.join(
|
||||
os.path.dirname(model_repo_path or "."), "Spark-TTS"
|
||||
)
|
||||
sparktts_pkg = os.path.join(spark_code_dir, "sparktts")
|
||||
if not os.path.isdir(sparktts_pkg):
|
||||
logger.info(f"Cloning SparkAudio/Spark-TTS to {spark_code_dir}...")
|
||||
subprocess.run(
|
||||
["git", "clone", "--depth", "1", "https://github.com/SparkAudio/Spark-TTS", spark_code_dir],
|
||||
check=True,
|
||||
[
|
||||
"git",
|
||||
"clone",
|
||||
"--depth",
|
||||
"1",
|
||||
"https://github.com/SparkAudio/Spark-TTS",
|
||||
spark_code_dir,
|
||||
],
|
||||
check = True,
|
||||
)
|
||||
|
||||
if spark_code_dir not in sys.path:
|
||||
|
|
@ -112,8 +130,15 @@ class AudioCodecManager:
|
|||
if not os.path.isdir(outetts_pkg):
|
||||
logger.info(f"Cloning edwko/OuteTTS to {outetts_code_dir}...")
|
||||
subprocess.run(
|
||||
["git", "clone", "--depth", "1", "https://github.com/edwko/OuteTTS", outetts_code_dir],
|
||||
check=True,
|
||||
[
|
||||
"git",
|
||||
"clone",
|
||||
"--depth",
|
||||
"1",
|
||||
"https://github.com/edwko/OuteTTS",
|
||||
outetts_code_dir,
|
||||
],
|
||||
check = True,
|
||||
)
|
||||
# Remove files that pull in heavy / incompatible dependencies
|
||||
# (matches notebook: gguf_model.py is under models/, others under outetts/)
|
||||
|
|
@ -134,17 +159,19 @@ class AudioCodecManager:
|
|||
from outetts.models.config import ModelConfig as OuteTTSModelConfig
|
||||
|
||||
dummy_config = OuteTTSModelConfig(
|
||||
tokenizer_path="OuteAI/Llama-OuteTTS-1.0-1B",
|
||||
device=device,
|
||||
audio_codec_path=None,
|
||||
tokenizer_path = "OuteAI/Llama-OuteTTS-1.0-1B",
|
||||
device = device,
|
||||
audio_codec_path = None,
|
||||
)
|
||||
processor = AudioProcessor(config=dummy_config)
|
||||
processor = AudioProcessor(config = dummy_config)
|
||||
self._dac_audio_codec = processor.audio_codec
|
||||
logger.info("Loaded DAC audio codec")
|
||||
|
||||
# ── Decoders ─────────────────────────────────────────────────
|
||||
|
||||
def decode_snac(self, generated_ids: torch.Tensor, device: str) -> Tuple[bytes, int]:
|
||||
def decode_snac(
|
||||
self, generated_ids: torch.Tensor, device: str
|
||||
) -> Tuple[bytes, int]:
|
||||
"""
|
||||
Decode SNAC tokens (Orpheus) into WAV bytes.
|
||||
|
||||
|
|
@ -155,12 +182,14 @@ class AudioCodecManager:
|
|||
Returns (wav_bytes, 24000).
|
||||
"""
|
||||
# Find START_OF_SPEECH token (128257)
|
||||
token_indices = (generated_ids == 128257).nonzero(as_tuple=True)
|
||||
token_indices = (generated_ids == 128257).nonzero(as_tuple = True)
|
||||
if len(token_indices[1]) > 0:
|
||||
cropped = generated_ids[:, token_indices[1][-1] + 1:]
|
||||
cropped = generated_ids[:, token_indices[1][-1] + 1 :]
|
||||
else:
|
||||
# Gracefully fall back to using entire output if marker not found
|
||||
logger.warning("No START_OF_SPEECH token (128257) found — using full generated output")
|
||||
logger.warning(
|
||||
"No START_OF_SPEECH token (128257) found — using full generated output"
|
||||
)
|
||||
cropped = generated_ids
|
||||
row = cropped[0]
|
||||
|
||||
|
|
@ -213,14 +242,20 @@ class AudioCodecManager:
|
|||
semantic_matches = re.findall(r"<\|bicodec_semantic_(\d+)\|>", generated_text)
|
||||
global_matches = re.findall(r"<\|bicodec_global_(\d+)\|>", generated_text)
|
||||
|
||||
logger.info(f"BiCodec decode: {len(global_matches)} global tokens, {len(semantic_matches)} semantic tokens")
|
||||
logger.info(
|
||||
f"BiCodec decode: {len(global_matches)} global tokens, {len(semantic_matches)} semantic tokens"
|
||||
)
|
||||
if len(global_matches) < 10:
|
||||
logger.info(f"BiCodec generated text (first 500 chars): {generated_text[:500]}")
|
||||
logger.info(
|
||||
f"BiCodec generated text (first 500 chars): {generated_text[:500]}"
|
||||
)
|
||||
|
||||
if not semantic_matches:
|
||||
raise ValueError("No bicodec_semantic tokens found in generated output")
|
||||
|
||||
semantic_ids = torch.tensor([int(t) for t in semantic_matches]).long().unsqueeze(0)
|
||||
semantic_ids = (
|
||||
torch.tensor([int(t) for t in semantic_matches]).long().unsqueeze(0)
|
||||
)
|
||||
|
||||
# Speaker encoder expects exactly 32 global tokens (token_num=32 in BiCodec config).
|
||||
# Pad with zeros or truncate to 32.
|
||||
|
|
@ -260,7 +295,7 @@ class AudioCodecManager:
|
|||
c1 = c1[:t]
|
||||
c2 = c2[:t]
|
||||
|
||||
codes = torch.tensor([[c1, c2]], dtype=torch.int64).to(device)
|
||||
codes = torch.tensor([[c1, c2]], dtype = torch.int64).to(device)
|
||||
with torch.no_grad():
|
||||
audio = self._dac_audio_codec.decode(codes)
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -7,6 +7,7 @@ llama-server inference backend for GGUF models.
|
|||
Manages a llama-server subprocess and proxies chat completions
|
||||
through its OpenAI-compatible /v1/chat/completions endpoint.
|
||||
"""
|
||||
|
||||
import atexit
|
||||
import json
|
||||
import structlog
|
||||
|
|
@ -146,7 +147,9 @@ class LlamaCppBackend:
|
|||
if build_path.is_file():
|
||||
return str(build_path)
|
||||
if sys.platform == "win32":
|
||||
win_path = project_root / "llama.cpp" / "build" / "bin" / "Release" / binary_name
|
||||
win_path = (
|
||||
project_root / "llama.cpp" / "build" / "bin" / "Release" / binary_name
|
||||
)
|
||||
if win_path.is_file():
|
||||
return str(win_path)
|
||||
|
||||
|
|
@ -168,7 +171,7 @@ class LlamaCppBackend:
|
|||
def _find_free_port() -> int:
|
||||
"""Find an available TCP port."""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("", 0))
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
# ── Stdout drain (prevents pipe deadlock on Windows) ─────────
|
||||
|
|
@ -258,15 +261,19 @@ class LlamaCppBackend:
|
|||
try:
|
||||
import re
|
||||
from huggingface_hub import list_repo_files
|
||||
files = list_repo_files(hf_repo, token=hf_token)
|
||||
|
||||
files = list_repo_files(hf_repo, token = hf_token)
|
||||
variant_lower = hf_variant.lower()
|
||||
# Use word-boundary matching so "Q8_0" doesn't also
|
||||
# match "IQ8_0" or other superset variant names.
|
||||
boundary = re.compile(
|
||||
r'(?<![a-zA-Z0-9])' + re.escape(variant_lower) + r'(?![a-zA-Z0-9])'
|
||||
r"(?<![a-zA-Z0-9])"
|
||||
+ re.escape(variant_lower)
|
||||
+ r"(?![a-zA-Z0-9])"
|
||||
)
|
||||
gguf_files = sorted(
|
||||
f for f in files
|
||||
f
|
||||
for f in files
|
||||
if f.endswith(".gguf") and boundary.search(f.lower())
|
||||
)
|
||||
if gguf_files:
|
||||
|
|
@ -274,17 +281,20 @@ class LlamaCppBackend:
|
|||
# For split GGUFs (e.g. model-Q8_0-00001-of-00003.gguf)
|
||||
# discover siblings by exact basename + total match
|
||||
# so "model-Q8_0-v2-*" isn't pulled in as a sibling.
|
||||
shard_pat = re.compile(r'^(.*)-\d{5}-of-(\d{5})\.gguf$')
|
||||
shard_pat = re.compile(r"^(.*)-\d{5}-of-(\d{5})\.gguf$")
|
||||
m = shard_pat.match(gguf_filename)
|
||||
if m:
|
||||
prefix = m.group(1)
|
||||
total = m.group(2)
|
||||
sibling_pat = re.compile(
|
||||
r'^' + re.escape(prefix) + r'-\d{5}-of-' + re.escape(total) + r'\.gguf$'
|
||||
r"^"
|
||||
+ re.escape(prefix)
|
||||
+ r"-\d{5}-of-"
|
||||
+ re.escape(total)
|
||||
+ r"\.gguf$"
|
||||
)
|
||||
gguf_extra_shards = [
|
||||
f for f in gguf_files[1:]
|
||||
if sibling_pat.match(f)
|
||||
f for f in gguf_files[1:] if sibling_pat.match(f)
|
||||
]
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not list repo files: {e}")
|
||||
|
|
@ -295,22 +305,28 @@ class LlamaCppBackend:
|
|||
repo_name = hf_repo.split("/")[-1].replace("-GGUF", "")
|
||||
gguf_filename = f"{repo_name}-{hf_variant}.gguf"
|
||||
|
||||
logger.info(f"Downloading GGUF: {hf_repo}/{gguf_filename}"
|
||||
+ (f" (+{len(gguf_extra_shards)} shards)" if gguf_extra_shards else ""))
|
||||
logger.info(
|
||||
f"Downloading GGUF: {hf_repo}/{gguf_filename}"
|
||||
+ (
|
||||
f" (+{len(gguf_extra_shards)} shards)"
|
||||
if gguf_extra_shards
|
||||
else ""
|
||||
)
|
||||
)
|
||||
try:
|
||||
local_path = hf_hub_download(
|
||||
repo_id=hf_repo,
|
||||
filename=gguf_filename,
|
||||
token=hf_token,
|
||||
repo_id = hf_repo,
|
||||
filename = gguf_filename,
|
||||
token = hf_token,
|
||||
)
|
||||
# Download remaining shards for split GGUFs — llama-server
|
||||
# auto-discovers them when they are in the same directory.
|
||||
for shard in gguf_extra_shards:
|
||||
logger.info(f"Downloading GGUF shard: {shard}")
|
||||
hf_hub_download(
|
||||
repo_id=hf_repo,
|
||||
filename=shard,
|
||||
token=hf_token,
|
||||
repo_id = hf_repo,
|
||||
filename = shard,
|
||||
token = hf_token,
|
||||
)
|
||||
except Exception as e:
|
||||
raise RuntimeError(
|
||||
|
|
@ -320,20 +336,28 @@ class LlamaCppBackend:
|
|||
logger.info(f"GGUF downloaded to: {local_path}")
|
||||
cmd = [
|
||||
binary,
|
||||
"-m", local_path,
|
||||
"--port", str(self._port),
|
||||
"-c", str(n_ctx),
|
||||
"-ngl", str(n_gpu_layers),
|
||||
"-m",
|
||||
local_path,
|
||||
"--port",
|
||||
str(self._port),
|
||||
"-c",
|
||||
str(n_ctx),
|
||||
"-ngl",
|
||||
str(n_gpu_layers),
|
||||
]
|
||||
elif gguf_path:
|
||||
if not Path(gguf_path).is_file():
|
||||
raise FileNotFoundError(f"GGUF file not found: {gguf_path}")
|
||||
cmd = [
|
||||
binary,
|
||||
"-m", gguf_path,
|
||||
"--port", str(self._port),
|
||||
"-c", str(n_ctx),
|
||||
"-ngl", str(n_gpu_layers),
|
||||
"-m",
|
||||
gguf_path,
|
||||
"--port",
|
||||
str(self._port),
|
||||
"-c",
|
||||
str(n_ctx),
|
||||
"-ngl",
|
||||
str(n_gpu_layers),
|
||||
]
|
||||
else:
|
||||
raise ValueError("Either gguf_path or hf_repo must be provided")
|
||||
|
|
@ -354,6 +378,7 @@ class LlamaCppBackend:
|
|||
# Set library paths so llama-server can find its shared libs and CUDA DLLs
|
||||
import os
|
||||
import sys
|
||||
|
||||
env = os.environ.copy()
|
||||
binary_dir = str(Path(binary).parent)
|
||||
|
||||
|
|
@ -375,20 +400,22 @@ class LlamaCppBackend:
|
|||
else:
|
||||
# Linux: set LD_LIBRARY_PATH for shared libs next to the binary
|
||||
existing_ld = env.get("LD_LIBRARY_PATH", "")
|
||||
env["LD_LIBRARY_PATH"] = f"{binary_dir}:{existing_ld}" if existing_ld else binary_dir
|
||||
env["LD_LIBRARY_PATH"] = (
|
||||
f"{binary_dir}:{existing_ld}" if existing_ld else binary_dir
|
||||
)
|
||||
|
||||
self._stdout_lines = []
|
||||
self._process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
text=True,
|
||||
env=env,
|
||||
stdout = subprocess.PIPE,
|
||||
stderr = subprocess.STDOUT,
|
||||
text = True,
|
||||
env = env,
|
||||
)
|
||||
|
||||
# Start background thread to drain stdout and prevent pipe deadlock
|
||||
self._stdout_thread = threading.Thread(
|
||||
target=self._drain_stdout, daemon=True, name="llama-stdout"
|
||||
target = self._drain_stdout, daemon = True, name = "llama-stdout"
|
||||
)
|
||||
self._stdout_thread.start()
|
||||
|
||||
|
|
@ -399,7 +426,7 @@ class LlamaCppBackend:
|
|||
self._model_identifier = model_identifier
|
||||
|
||||
# Wait for llama-server to become healthy
|
||||
if not self._wait_for_health(timeout=120.0):
|
||||
if not self._wait_for_health(timeout = 120.0):
|
||||
self._kill_process()
|
||||
raise RuntimeError(
|
||||
"llama-server failed to start. "
|
||||
|
|
@ -434,17 +461,17 @@ class LlamaCppBackend:
|
|||
return
|
||||
try:
|
||||
self._process.terminate()
|
||||
self._process.wait(timeout=5)
|
||||
self._process.wait(timeout = 5)
|
||||
except subprocess.TimeoutExpired:
|
||||
logger.warning("llama-server did not exit on SIGTERM, sending SIGKILL")
|
||||
self._process.kill()
|
||||
self._process.wait(timeout=5)
|
||||
self._process.wait(timeout = 5)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error killing llama-server process: {e}")
|
||||
finally:
|
||||
self._process = None
|
||||
if self._stdout_thread is not None:
|
||||
self._stdout_thread.join(timeout=2)
|
||||
self._stdout_thread.join(timeout = 2)
|
||||
self._stdout_thread = None
|
||||
|
||||
def _cleanup(self):
|
||||
|
|
@ -465,7 +492,7 @@ class LlamaCppBackend:
|
|||
if self._process.poll() is not None:
|
||||
# Give the drain thread a moment to collect final output
|
||||
if self._stdout_thread is not None:
|
||||
self._stdout_thread.join(timeout=2)
|
||||
self._stdout_thread.join(timeout = 2)
|
||||
output = "\n".join(self._stdout_lines[-50:])
|
||||
logger.error(
|
||||
f"llama-server exited with code {self._process.returncode}. "
|
||||
|
|
@ -474,7 +501,7 @@ class LlamaCppBackend:
|
|||
return False
|
||||
|
||||
try:
|
||||
resp = httpx.get(url, timeout=2.0)
|
||||
resp = httpx.get(url, timeout = 2.0)
|
||||
if resp.status_code == 200:
|
||||
return True
|
||||
except (httpx.ConnectError, httpx.TimeoutException):
|
||||
|
|
@ -567,8 +594,8 @@ class LlamaCppBackend:
|
|||
cumulative = ""
|
||||
|
||||
try:
|
||||
with httpx.Client(timeout=None) as client:
|
||||
with client.stream("POST", url, json=payload) as response:
|
||||
with httpx.Client(timeout = None) as client:
|
||||
with client.stream("POST", url, json = payload) as response:
|
||||
if response.status_code != 200:
|
||||
error_body = response.read().decode()
|
||||
raise RuntimeError(
|
||||
|
|
@ -602,7 +629,9 @@ class LlamaCppBackend:
|
|||
cumulative += token
|
||||
yield cumulative
|
||||
except json.JSONDecodeError:
|
||||
logger.debug(f"Skipping malformed SSE line: {line[:100]}")
|
||||
logger.debug(
|
||||
f"Skipping malformed SSE line: {line[:100]}"
|
||||
)
|
||||
|
||||
except httpx.ConnectError:
|
||||
raise RuntimeError("Lost connection to llama-server")
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ is killed and a new one is spawned with the correct version.
|
|||
|
||||
Pattern follows core/training/training.py.
|
||||
"""
|
||||
|
||||
import atexit
|
||||
import base64
|
||||
import structlog
|
||||
|
|
@ -48,7 +49,9 @@ class InferenceOrchestrator:
|
|||
self._resp_queue: Any = None
|
||||
self._cancel_event: Any = None # mp.Event — set to cancel generation instantly
|
||||
self._lock = threading.Lock()
|
||||
self._gen_lock = threading.Lock() # Serializes generation — one request at a time
|
||||
self._gen_lock = (
|
||||
threading.Lock()
|
||||
) # Serializes generation — one request at a time
|
||||
|
||||
# Local state mirrors (updated from subprocess responses)
|
||||
self.active_model_name: Optional[str] = None
|
||||
|
|
@ -83,14 +86,14 @@ class InferenceOrchestrator:
|
|||
self._cancel_event = _CTX.Event()
|
||||
|
||||
self._proc = _CTX.Process(
|
||||
target=run_inference_process,
|
||||
kwargs={
|
||||
target = run_inference_process,
|
||||
kwargs = {
|
||||
"cmd_queue": self._cmd_queue,
|
||||
"resp_queue": self._resp_queue,
|
||||
"cancel_event": self._cancel_event,
|
||||
"config": config,
|
||||
},
|
||||
daemon=True,
|
||||
daemon = True,
|
||||
)
|
||||
self._proc.start()
|
||||
logger.info("Inference subprocess started (pid=%s)", self._proc.pid)
|
||||
|
|
@ -121,7 +124,7 @@ class InferenceOrchestrator:
|
|||
|
||||
# 4. Wait for graceful shutdown
|
||||
try:
|
||||
self._proc.join(timeout=timeout)
|
||||
self._proc.join(timeout = timeout)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
@ -130,14 +133,14 @@ class InferenceOrchestrator:
|
|||
logger.warning("Inference subprocess did not exit gracefully, terminating")
|
||||
try:
|
||||
self._proc.terminate()
|
||||
self._proc.join(timeout=5)
|
||||
self._proc.join(timeout = 5)
|
||||
except Exception:
|
||||
pass
|
||||
if self._proc is not None and self._proc.is_alive():
|
||||
logger.warning("Subprocess still alive after terminate, killing")
|
||||
try:
|
||||
self._proc.kill()
|
||||
self._proc.join(timeout=3)
|
||||
self._proc.join(timeout = 3)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
@ -149,7 +152,7 @@ class InferenceOrchestrator:
|
|||
|
||||
def _cleanup(self):
|
||||
"""atexit handler."""
|
||||
self._shutdown_subprocess(timeout=5.0)
|
||||
self._shutdown_subprocess(timeout = 5.0)
|
||||
|
||||
def _ensure_subprocess_alive(self) -> bool:
|
||||
"""Check if subprocess is alive."""
|
||||
|
|
@ -173,15 +176,13 @@ class InferenceOrchestrator:
|
|||
if self._resp_queue is None:
|
||||
return None
|
||||
try:
|
||||
return self._resp_queue.get(timeout=timeout)
|
||||
return self._resp_queue.get(timeout = timeout)
|
||||
except queue.Empty:
|
||||
return None
|
||||
except (EOFError, OSError, ValueError):
|
||||
return None
|
||||
|
||||
def _wait_response(
|
||||
self, expected_type: str, timeout: float = 120.0
|
||||
) -> dict:
|
||||
def _wait_response(self, expected_type: str, timeout: float = 120.0) -> dict:
|
||||
"""Block until a response of the expected type arrives.
|
||||
|
||||
Also handles 'status' and 'error' events during the wait.
|
||||
|
|
@ -192,7 +193,7 @@ class InferenceOrchestrator:
|
|||
|
||||
while time.monotonic() < deadline:
|
||||
remaining = max(0.1, deadline - time.monotonic())
|
||||
resp = self._read_resp(timeout=min(remaining, 1.0))
|
||||
resp = self._read_resp(timeout = min(remaining, 1.0))
|
||||
|
||||
if resp is None:
|
||||
# Check subprocess health
|
||||
|
|
@ -214,7 +215,11 @@ class InferenceOrchestrator:
|
|||
continue
|
||||
|
||||
# Other response types during wait — skip
|
||||
logger.debug("Skipping response type '%s' while waiting for '%s'", rtype, expected_type)
|
||||
logger.debug(
|
||||
"Skipping response type '%s' while waiting for '%s'",
|
||||
rtype,
|
||||
expected_type,
|
||||
)
|
||||
|
||||
raise RuntimeError(
|
||||
f"Timeout waiting for '{expected_type}' response after {timeout}s"
|
||||
|
|
@ -241,7 +246,7 @@ class InferenceOrchestrator:
|
|||
"""
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
resp = self._read_resp(timeout=min(0.5, deadline - time.monotonic()))
|
||||
resp = self._read_resp(timeout = min(0.5, deadline - time.monotonic()))
|
||||
if resp is None:
|
||||
if not self._ensure_subprocess_alive():
|
||||
return
|
||||
|
|
@ -259,7 +264,7 @@ class InferenceOrchestrator:
|
|||
self,
|
||||
config, # ModelConfig
|
||||
max_seq_length: int = 2048,
|
||||
dtype=None,
|
||||
dtype = None,
|
||||
load_in_4bit: bool = True,
|
||||
hf_token: Optional[str] = None,
|
||||
trust_remote_code: bool = False,
|
||||
|
|
@ -298,14 +303,15 @@ class InferenceOrchestrator:
|
|||
|
||||
elif self._proc is not None:
|
||||
# Dead subprocess — clean up
|
||||
self._shutdown_subprocess(timeout=2)
|
||||
self._shutdown_subprocess(timeout = 2)
|
||||
|
||||
logger.info(
|
||||
"Spawning fresh inference subprocess for '%s' (transformers %s.x)",
|
||||
model_name, needed_major,
|
||||
model_name,
|
||||
needed_major,
|
||||
)
|
||||
self._spawn_subprocess(sub_config)
|
||||
resp = self._wait_response("loaded", timeout=180)
|
||||
resp = self._wait_response("loaded", timeout = 180)
|
||||
|
||||
# Update local state from response
|
||||
if resp.get("success"):
|
||||
|
|
@ -346,11 +352,13 @@ class InferenceOrchestrator:
|
|||
return True
|
||||
|
||||
try:
|
||||
self._send_cmd({
|
||||
"type": "unload",
|
||||
"model_name": model_name,
|
||||
})
|
||||
resp = self._wait_response("unloaded", timeout=30)
|
||||
self._send_cmd(
|
||||
{
|
||||
"type": "unload",
|
||||
"model_name": model_name,
|
||||
}
|
||||
)
|
||||
resp = self._wait_response("unloaded", timeout = 30)
|
||||
|
||||
# Update local state
|
||||
self.models.pop(model_name, None)
|
||||
|
|
@ -372,40 +380,40 @@ class InferenceOrchestrator:
|
|||
self,
|
||||
messages: list,
|
||||
system_prompt: str = "",
|
||||
image=None,
|
||||
image = None,
|
||||
temperature: float = 0.7,
|
||||
top_p: float = 0.9,
|
||||
top_k: int = 40,
|
||||
min_p: float = 0.0,
|
||||
max_new_tokens: int = 256,
|
||||
repetition_penalty: float = 1.1,
|
||||
cancel_event=None,
|
||||
cancel_event = None,
|
||||
) -> Generator[str, None, None]:
|
||||
"""Generate response, streaming tokens from subprocess."""
|
||||
yield from self._generate_inner(
|
||||
messages=messages,
|
||||
system_prompt=system_prompt,
|
||||
image=image,
|
||||
temperature=temperature,
|
||||
top_p=top_p,
|
||||
top_k=top_k,
|
||||
min_p=min_p,
|
||||
max_new_tokens=max_new_tokens,
|
||||
repetition_penalty=repetition_penalty,
|
||||
cancel_event=cancel_event,
|
||||
use_adapter=None,
|
||||
messages = messages,
|
||||
system_prompt = system_prompt,
|
||||
image = image,
|
||||
temperature = temperature,
|
||||
top_p = top_p,
|
||||
top_k = top_k,
|
||||
min_p = min_p,
|
||||
max_new_tokens = max_new_tokens,
|
||||
repetition_penalty = repetition_penalty,
|
||||
cancel_event = cancel_event,
|
||||
use_adapter = None,
|
||||
)
|
||||
|
||||
def generate_with_adapter_control(
|
||||
self,
|
||||
use_adapter: Optional[Union[bool, str]] = None,
|
||||
cancel_event=None,
|
||||
cancel_event = None,
|
||||
**gen_kwargs,
|
||||
) -> Generator[str, None, None]:
|
||||
"""Generate with adapter control, streaming tokens from subprocess."""
|
||||
yield from self._generate_inner(
|
||||
use_adapter=use_adapter,
|
||||
cancel_event=cancel_event,
|
||||
use_adapter = use_adapter,
|
||||
cancel_event = cancel_event,
|
||||
**gen_kwargs,
|
||||
)
|
||||
|
||||
|
|
@ -413,15 +421,15 @@ class InferenceOrchestrator:
|
|||
self,
|
||||
messages: list = None,
|
||||
system_prompt: str = "",
|
||||
image=None,
|
||||
image = None,
|
||||
temperature: float = 0.7,
|
||||
top_p: float = 0.9,
|
||||
top_k: int = 40,
|
||||
min_p: float = 0.0,
|
||||
max_new_tokens: int = 256,
|
||||
repetition_penalty: float = 1.1,
|
||||
cancel_event=None,
|
||||
use_adapter=None,
|
||||
cancel_event = None,
|
||||
use_adapter = None,
|
||||
) -> Generator[str, None, None]:
|
||||
"""Inner generation logic — sends command to subprocess, yields tokens.
|
||||
|
||||
|
|
@ -442,32 +450,32 @@ class InferenceOrchestrator:
|
|||
# can consume and drop each other's token events.
|
||||
with self._gen_lock:
|
||||
yield from self._generate_locked(
|
||||
messages=messages,
|
||||
system_prompt=system_prompt,
|
||||
image=image,
|
||||
temperature=temperature,
|
||||
top_p=top_p,
|
||||
top_k=top_k,
|
||||
min_p=min_p,
|
||||
max_new_tokens=max_new_tokens,
|
||||
repetition_penalty=repetition_penalty,
|
||||
cancel_event=cancel_event,
|
||||
use_adapter=use_adapter,
|
||||
messages = messages,
|
||||
system_prompt = system_prompt,
|
||||
image = image,
|
||||
temperature = temperature,
|
||||
top_p = top_p,
|
||||
top_k = top_k,
|
||||
min_p = min_p,
|
||||
max_new_tokens = max_new_tokens,
|
||||
repetition_penalty = repetition_penalty,
|
||||
cancel_event = cancel_event,
|
||||
use_adapter = use_adapter,
|
||||
)
|
||||
|
||||
def _generate_locked(
|
||||
self,
|
||||
messages: list = None,
|
||||
system_prompt: str = "",
|
||||
image=None,
|
||||
image = None,
|
||||
temperature: float = 0.7,
|
||||
top_p: float = 0.9,
|
||||
top_k: int = 40,
|
||||
min_p: float = 0.0,
|
||||
max_new_tokens: int = 256,
|
||||
repetition_penalty: float = 1.1,
|
||||
cancel_event=None,
|
||||
use_adapter=None,
|
||||
cancel_event = None,
|
||||
use_adapter = None,
|
||||
) -> Generator[str, None, None]:
|
||||
"""Actual generation logic — must be called under _gen_lock."""
|
||||
request_id = str(uuid.uuid4())
|
||||
|
|
@ -503,7 +511,7 @@ class InferenceOrchestrator:
|
|||
# Yield tokens from response queue — we are the only reader
|
||||
# because _gen_lock is held.
|
||||
while True:
|
||||
resp = self._read_resp(timeout=30.0)
|
||||
resp = self._read_resp(timeout = 30.0)
|
||||
|
||||
if resp is None:
|
||||
# Check subprocess health
|
||||
|
|
@ -531,7 +539,7 @@ class InferenceOrchestrator:
|
|||
# Wait for the subprocess to acknowledge cancellation
|
||||
# (gen_done/gen_error) so stale events don't leak into
|
||||
# the next generation request.
|
||||
self._drain_until_gen_done(timeout=5.0)
|
||||
self._drain_until_gen_done(timeout = 5.0)
|
||||
return
|
||||
yield resp.get("text", "")
|
||||
|
||||
|
|
@ -577,6 +585,7 @@ class InferenceOrchestrator:
|
|||
raise RuntimeError("No active model")
|
||||
|
||||
import uuid
|
||||
|
||||
request_id = str(uuid.uuid4())
|
||||
|
||||
cmd = {
|
||||
|
|
@ -599,11 +608,13 @@ class InferenceOrchestrator:
|
|||
deadline = time.monotonic() + 120.0
|
||||
while time.monotonic() < deadline:
|
||||
remaining = max(0.1, deadline - time.monotonic())
|
||||
resp = self._read_resp(timeout=min(remaining, 1.0))
|
||||
resp = self._read_resp(timeout = min(remaining, 1.0))
|
||||
|
||||
if resp is None:
|
||||
if not self._ensure_subprocess_alive():
|
||||
raise RuntimeError("Inference subprocess crashed during audio generation")
|
||||
raise RuntimeError(
|
||||
"Inference subprocess crashed during audio generation"
|
||||
)
|
||||
continue
|
||||
|
||||
rtype = resp.get("type", "")
|
||||
|
|
@ -627,15 +638,15 @@ class InferenceOrchestrator:
|
|||
def generate_whisper_response(
|
||||
self,
|
||||
audio_array,
|
||||
cancel_event=None,
|
||||
cancel_event = None,
|
||||
) -> Generator[str, None, None]:
|
||||
"""Whisper ASR — sends audio to subprocess, yields text."""
|
||||
yield from self._generate_audio_input_inner(
|
||||
audio_array=audio_array,
|
||||
audio_type="whisper",
|
||||
messages=[],
|
||||
system_prompt="",
|
||||
cancel_event=cancel_event,
|
||||
audio_array = audio_array,
|
||||
audio_type = "whisper",
|
||||
messages = [],
|
||||
system_prompt = "",
|
||||
cancel_event = cancel_event,
|
||||
)
|
||||
|
||||
def generate_audio_input_response(
|
||||
|
|
@ -649,21 +660,21 @@ class InferenceOrchestrator:
|
|||
min_p: float = 0.0,
|
||||
max_new_tokens: int = 512,
|
||||
repetition_penalty: float = 1.1,
|
||||
cancel_event=None,
|
||||
cancel_event = None,
|
||||
) -> Generator[str, None, None]:
|
||||
"""Audio input generation (e.g. Gemma 3n) — streams text tokens."""
|
||||
yield from self._generate_audio_input_inner(
|
||||
audio_array=audio_array,
|
||||
audio_type=None, # worker will use generate_audio_input_response
|
||||
messages=messages,
|
||||
system_prompt=system_prompt,
|
||||
temperature=temperature,
|
||||
top_p=top_p,
|
||||
top_k=top_k,
|
||||
min_p=min_p,
|
||||
max_new_tokens=max_new_tokens,
|
||||
repetition_penalty=repetition_penalty,
|
||||
cancel_event=cancel_event,
|
||||
audio_array = audio_array,
|
||||
audio_type = None, # worker will use generate_audio_input_response
|
||||
messages = messages,
|
||||
system_prompt = system_prompt,
|
||||
temperature = temperature,
|
||||
top_p = top_p,
|
||||
top_k = top_k,
|
||||
min_p = min_p,
|
||||
max_new_tokens = max_new_tokens,
|
||||
repetition_penalty = repetition_penalty,
|
||||
cancel_event = cancel_event,
|
||||
)
|
||||
|
||||
def _generate_audio_input_inner(
|
||||
|
|
@ -678,7 +689,7 @@ class InferenceOrchestrator:
|
|||
min_p: float = 0.0,
|
||||
max_new_tokens: int = 512,
|
||||
repetition_penalty: float = 1.1,
|
||||
cancel_event=None,
|
||||
cancel_event = None,
|
||||
) -> Generator[str, None, None]:
|
||||
"""Shared inner logic for audio input generation (Whisper + ASR)."""
|
||||
if not self._ensure_subprocess_alive():
|
||||
|
|
@ -690,10 +701,15 @@ class InferenceOrchestrator:
|
|||
|
||||
with self._gen_lock:
|
||||
import uuid
|
||||
|
||||
request_id = str(uuid.uuid4())
|
||||
|
||||
# Convert numpy array to list for mp.Queue serialization
|
||||
audio_data = audio_array.tolist() if hasattr(audio_array, 'tolist') else list(audio_array)
|
||||
audio_data = (
|
||||
audio_array.tolist()
|
||||
if hasattr(audio_array, "tolist")
|
||||
else list(audio_array)
|
||||
)
|
||||
|
||||
cmd = {
|
||||
"type": "generate_audio_input",
|
||||
|
|
@ -718,7 +734,7 @@ class InferenceOrchestrator:
|
|||
|
||||
# Yield tokens — same pattern as _generate_locked
|
||||
while True:
|
||||
resp = self._read_resp(timeout=30.0)
|
||||
resp = self._read_resp(timeout = 30.0)
|
||||
|
||||
if resp is None:
|
||||
if not self._ensure_subprocess_alive():
|
||||
|
|
@ -738,7 +754,7 @@ class InferenceOrchestrator:
|
|||
if rtype == "token":
|
||||
if cancel_event is not None and cancel_event.is_set():
|
||||
self._cancel_generation()
|
||||
self._drain_until_gen_done(timeout=5.0)
|
||||
self._drain_until_gen_done(timeout = 5.0)
|
||||
return
|
||||
yield resp.get("text", "")
|
||||
|
||||
|
|
@ -761,6 +777,7 @@ class InferenceOrchestrator:
|
|||
return None
|
||||
if img.size[0] > max_size or img.size[1] > max_size:
|
||||
from PIL import Image
|
||||
|
||||
ratio = min(max_size / img.size[0], max_size / img.size[1])
|
||||
new_size = (int(img.size[0] * ratio), int(img.size[1] * ratio))
|
||||
return img.resize(new_size, Image.Resampling.LANCZOS)
|
||||
|
|
@ -770,7 +787,7 @@ class InferenceOrchestrator:
|
|||
def _pil_to_base64(img) -> str:
|
||||
"""Convert a PIL Image to base64 string for IPC."""
|
||||
buf = BytesIO()
|
||||
img.save(buf, format="PNG")
|
||||
img.save(buf, format = "PNG")
|
||||
return base64.b64encode(buf.getvalue()).decode("ascii")
|
||||
|
||||
def get_current_model(self) -> Optional[str]:
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ The subprocess stays alive while a model is loaded, accepting commands
|
|||
|
||||
Pattern follows core/training/worker.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
|
|
@ -45,7 +46,9 @@ def _activate_transformers_version(model_name: str) -> None:
|
|||
|
||||
resolved = _resolve_base_model(model_name)
|
||||
if needs_transformers_5(resolved):
|
||||
venv_t5 = os.path.join(os.path.expanduser("~"), ".unsloth", "studio", ".venv_t5")
|
||||
venv_t5 = os.path.join(
|
||||
os.path.expanduser("~"), ".unsloth", "studio", ".venv_t5"
|
||||
)
|
||||
if os.path.isdir(venv_t5):
|
||||
sys.path.insert(0, venv_t5)
|
||||
logger.info("Activated transformers 5.x from %s", venv_t5)
|
||||
|
|
@ -53,16 +56,35 @@ def _activate_transformers_version(model_name: str) -> None:
|
|||
# Fallback: pip install at runtime (slower, ~10-15s)
|
||||
logger.warning(".venv_t5 not found at %s — installing at runtime", venv_t5)
|
||||
import subprocess as sp
|
||||
os.makedirs(venv_t5, exist_ok=True)
|
||||
|
||||
os.makedirs(venv_t5, exist_ok = True)
|
||||
r1 = sp.run(
|
||||
[sys.executable, "-m", "pip", "install", "--target", venv_t5,
|
||||
"--no-deps", "transformers==5.2.0"],
|
||||
stdout=sp.PIPE, stderr=sp.STDOUT,
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"--target",
|
||||
venv_t5,
|
||||
"--no-deps",
|
||||
"transformers==5.2.0",
|
||||
],
|
||||
stdout = sp.PIPE,
|
||||
stderr = sp.STDOUT,
|
||||
)
|
||||
r2 = sp.run(
|
||||
[sys.executable, "-m", "pip", "install", "--target", venv_t5,
|
||||
"--no-deps", "huggingface_hub==1.3.0"],
|
||||
stdout=sp.PIPE, stderr=sp.STDOUT,
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"--target",
|
||||
venv_t5,
|
||||
"--no-deps",
|
||||
"huggingface_hub==1.3.0",
|
||||
],
|
||||
stdout = sp.PIPE,
|
||||
stderr = sp.STDOUT,
|
||||
)
|
||||
if r1.returncode != 0 or r2.returncode != 0:
|
||||
raise RuntimeError(
|
||||
|
|
@ -80,6 +102,7 @@ def _activate_transformers_version(model_name: str) -> None:
|
|||
def _decode_image(image_base64: str):
|
||||
"""Decode base64 string to PIL.Image."""
|
||||
from PIL import Image
|
||||
|
||||
image_data = base64.b64decode(image_base64)
|
||||
return Image.open(BytesIO(image_data))
|
||||
|
||||
|
|
@ -90,6 +113,7 @@ def _resize_image(img, max_size: int = 800):
|
|||
return None
|
||||
if img.size[0] > max_size or img.size[1] > max_size:
|
||||
from PIL import Image
|
||||
|
||||
ratio = min(max_size / img.size[0], max_size / img.size[1])
|
||||
new_size = (int(img.size[0] * ratio), int(img.size[1] * ratio))
|
||||
return img.resize(new_size, Image.Resampling.LANCZOS)
|
||||
|
|
@ -114,9 +138,9 @@ def _build_model_config(config: dict):
|
|||
gguf_variant = config.get("gguf_variant")
|
||||
|
||||
mc = ModelConfig.from_identifier(
|
||||
model_id=model_name,
|
||||
hf_token=hf_token,
|
||||
gguf_variant=gguf_variant,
|
||||
model_id = model_name,
|
||||
hf_token = hf_token,
|
||||
gguf_variant = gguf_variant,
|
||||
)
|
||||
if not mc:
|
||||
raise ValueError(f"Invalid model identifier: {model_name}")
|
||||
|
|
@ -136,6 +160,7 @@ def _handle_load(backend, config: dict, resp_queue: Any) -> None:
|
|||
if mc.is_lora and mc.path:
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
adapter_cfg_path = Path(mc.path) / "adapter_config.json"
|
||||
if adapter_cfg_path.exists():
|
||||
try:
|
||||
|
|
@ -143,24 +168,34 @@ def _handle_load(backend, config: dict, resp_queue: Any) -> None:
|
|||
adapter_cfg = json.load(f)
|
||||
training_method = adapter_cfg.get("unsloth_training_method")
|
||||
if training_method == "lora" and load_in_4bit:
|
||||
logger.info("adapter_config.json says lora — setting load_in_4bit=False")
|
||||
logger.info(
|
||||
"adapter_config.json says lora — setting load_in_4bit=False"
|
||||
)
|
||||
load_in_4bit = False
|
||||
elif training_method == "qlora" and not load_in_4bit:
|
||||
logger.info("adapter_config.json says qlora — setting load_in_4bit=True")
|
||||
logger.info(
|
||||
"adapter_config.json says qlora — setting load_in_4bit=True"
|
||||
)
|
||||
load_in_4bit = True
|
||||
elif not training_method:
|
||||
if mc.base_model and "-bnb-4bit" not in mc.base_model.lower() and load_in_4bit:
|
||||
logger.info("No training method, base model has no -bnb-4bit — setting load_in_4bit=False")
|
||||
if (
|
||||
mc.base_model
|
||||
and "-bnb-4bit" not in mc.base_model.lower()
|
||||
and load_in_4bit
|
||||
):
|
||||
logger.info(
|
||||
"No training method, base model has no -bnb-4bit — setting load_in_4bit=False"
|
||||
)
|
||||
load_in_4bit = False
|
||||
except Exception as e:
|
||||
logger.warning("Could not read adapter_config.json: %s", e)
|
||||
|
||||
success = backend.load_model(
|
||||
config=mc,
|
||||
max_seq_length=config.get("max_seq_length", 2048),
|
||||
load_in_4bit=load_in_4bit,
|
||||
hf_token=hf_token,
|
||||
trust_remote_code=config.get("trust_remote_code", False),
|
||||
config = mc,
|
||||
max_seq_length = config.get("max_seq_length", 2048),
|
||||
load_in_4bit = load_in_4bit,
|
||||
hf_token = hf_token,
|
||||
trust_remote_code = config.get("trust_remote_code", False),
|
||||
)
|
||||
|
||||
if success:
|
||||
|
|
@ -175,28 +210,37 @@ def _handle_load(backend, config: dict, resp_queue: Any) -> None:
|
|||
"audio_type": getattr(mc, "audio_type", None),
|
||||
"has_audio_input": getattr(mc, "has_audio_input", False),
|
||||
}
|
||||
_send_response(resp_queue, {
|
||||
"type": "loaded",
|
||||
"success": True,
|
||||
"model_info": model_info,
|
||||
"ts": time.time(),
|
||||
})
|
||||
_send_response(
|
||||
resp_queue,
|
||||
{
|
||||
"type": "loaded",
|
||||
"success": True,
|
||||
"model_info": model_info,
|
||||
"ts": time.time(),
|
||||
},
|
||||
)
|
||||
else:
|
||||
_send_response(resp_queue, {
|
||||
"type": "loaded",
|
||||
"success": False,
|
||||
"error": "Failed to load model",
|
||||
"ts": time.time(),
|
||||
})
|
||||
_send_response(
|
||||
resp_queue,
|
||||
{
|
||||
"type": "loaded",
|
||||
"success": False,
|
||||
"error": "Failed to load model",
|
||||
"ts": time.time(),
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
_send_response(resp_queue, {
|
||||
"type": "loaded",
|
||||
"success": False,
|
||||
"error": str(exc),
|
||||
"stack": traceback.format_exc(limit=20),
|
||||
"ts": time.time(),
|
||||
})
|
||||
_send_response(
|
||||
resp_queue,
|
||||
{
|
||||
"type": "loaded",
|
||||
"success": False,
|
||||
"error": str(exc),
|
||||
"stack": traceback.format_exc(limit = 20),
|
||||
"ts": time.time(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _handle_generate(
|
||||
|
|
@ -240,7 +284,7 @@ def _handle_generate(
|
|||
use_adapter = cmd.get("use_adapter")
|
||||
if use_adapter is not None:
|
||||
generator = backend.generate_with_adapter_control(
|
||||
use_adapter=use_adapter,
|
||||
use_adapter = use_adapter,
|
||||
**gen_kwargs,
|
||||
)
|
||||
else:
|
||||
|
|
@ -254,29 +298,38 @@ def _handle_generate(
|
|||
logger.info("Generation cancelled for request %s", request_id)
|
||||
break
|
||||
|
||||
_send_response(resp_queue, {
|
||||
"type": "token",
|
||||
"request_id": request_id,
|
||||
"text": cumulative_text,
|
||||
"ts": time.time(),
|
||||
})
|
||||
_send_response(
|
||||
resp_queue,
|
||||
{
|
||||
"type": "token",
|
||||
"request_id": request_id,
|
||||
"text": cumulative_text,
|
||||
"ts": time.time(),
|
||||
},
|
||||
)
|
||||
|
||||
_send_response(resp_queue, {
|
||||
"type": "gen_done",
|
||||
"request_id": request_id,
|
||||
"ts": time.time(),
|
||||
})
|
||||
_send_response(
|
||||
resp_queue,
|
||||
{
|
||||
"type": "gen_done",
|
||||
"request_id": request_id,
|
||||
"ts": time.time(),
|
||||
},
|
||||
)
|
||||
logger.info("Finished text generation for request_id=%s", request_id)
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("Generation error: %s", exc, exc_info=True)
|
||||
_send_response(resp_queue, {
|
||||
"type": "gen_error",
|
||||
"request_id": request_id,
|
||||
"error": str(exc),
|
||||
"stack": traceback.format_exc(limit=20),
|
||||
"ts": time.time(),
|
||||
})
|
||||
logger.error("Generation error: %s", exc, exc_info = True)
|
||||
_send_response(
|
||||
resp_queue,
|
||||
{
|
||||
"type": "gen_error",
|
||||
"request_id": request_id,
|
||||
"error": str(exc),
|
||||
"stack": traceback.format_exc(limit = 20),
|
||||
"ts": time.time(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _handle_generate_audio(
|
||||
|
|
@ -289,35 +342,41 @@ def _handle_generate_audio(
|
|||
try:
|
||||
logger.info("Starting audio generation for request_id=%s", request_id)
|
||||
wav_bytes, sample_rate = backend.generate_audio_response(
|
||||
text=cmd["text"],
|
||||
temperature=cmd.get("temperature", 0.6),
|
||||
top_p=cmd.get("top_p", 0.95),
|
||||
top_k=cmd.get("top_k", 50),
|
||||
min_p=cmd.get("min_p", 0.0),
|
||||
max_new_tokens=cmd.get("max_new_tokens", 2048),
|
||||
repetition_penalty=cmd.get("repetition_penalty", 1.1),
|
||||
use_adapter=cmd.get("use_adapter"),
|
||||
text = cmd["text"],
|
||||
temperature = cmd.get("temperature", 0.6),
|
||||
top_p = cmd.get("top_p", 0.95),
|
||||
top_k = cmd.get("top_k", 50),
|
||||
min_p = cmd.get("min_p", 0.0),
|
||||
max_new_tokens = cmd.get("max_new_tokens", 2048),
|
||||
repetition_penalty = cmd.get("repetition_penalty", 1.1),
|
||||
use_adapter = cmd.get("use_adapter"),
|
||||
)
|
||||
|
||||
# Send WAV bytes as base64 (bytes can't go through mp.Queue directly)
|
||||
_send_response(resp_queue, {
|
||||
"type": "audio_done",
|
||||
"request_id": request_id,
|
||||
"wav_base64": base64.b64encode(wav_bytes).decode("ascii"),
|
||||
"sample_rate": sample_rate,
|
||||
"ts": time.time(),
|
||||
})
|
||||
_send_response(
|
||||
resp_queue,
|
||||
{
|
||||
"type": "audio_done",
|
||||
"request_id": request_id,
|
||||
"wav_base64": base64.b64encode(wav_bytes).decode("ascii"),
|
||||
"sample_rate": sample_rate,
|
||||
"ts": time.time(),
|
||||
},
|
||||
)
|
||||
logger.info("Finished audio generation for request_id=%s", request_id)
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("Audio generation error: %s", exc, exc_info=True)
|
||||
_send_response(resp_queue, {
|
||||
"type": "audio_error",
|
||||
"request_id": request_id,
|
||||
"error": str(exc),
|
||||
"stack": traceback.format_exc(limit=20),
|
||||
"ts": time.time(),
|
||||
})
|
||||
logger.error("Audio generation error: %s", exc, exc_info = True)
|
||||
_send_response(
|
||||
resp_queue,
|
||||
{
|
||||
"type": "audio_error",
|
||||
"request_id": request_id,
|
||||
"error": str(exc),
|
||||
"stack": traceback.format_exc(limit = 20),
|
||||
"ts": time.time(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _handle_generate_audio_input(
|
||||
|
|
@ -333,59 +392,70 @@ def _handle_generate_audio_input(
|
|||
import numpy as np
|
||||
|
||||
# Decode audio array from list (numpy arrays can't go through mp.Queue)
|
||||
audio_array = np.array(cmd["audio_data"], dtype=np.float32)
|
||||
audio_array = np.array(cmd["audio_data"], dtype = np.float32)
|
||||
|
||||
audio_type = cmd.get("audio_type")
|
||||
|
||||
if audio_type == "whisper":
|
||||
generator = backend.generate_whisper_response(
|
||||
audio_array=audio_array,
|
||||
cancel_event=cancel_event,
|
||||
audio_array = audio_array,
|
||||
cancel_event = cancel_event,
|
||||
)
|
||||
else:
|
||||
generator = backend.generate_audio_input_response(
|
||||
messages=cmd.get("messages", []),
|
||||
system_prompt=cmd.get("system_prompt", ""),
|
||||
audio_array=audio_array,
|
||||
temperature=cmd.get("temperature", 0.7),
|
||||
top_p=cmd.get("top_p", 0.9),
|
||||
top_k=cmd.get("top_k", 40),
|
||||
min_p=cmd.get("min_p", 0.0),
|
||||
max_new_tokens=cmd.get("max_new_tokens", 512),
|
||||
repetition_penalty=cmd.get("repetition_penalty", 1.1),
|
||||
cancel_event=cancel_event,
|
||||
messages = cmd.get("messages", []),
|
||||
system_prompt = cmd.get("system_prompt", ""),
|
||||
audio_array = audio_array,
|
||||
temperature = cmd.get("temperature", 0.7),
|
||||
top_p = cmd.get("top_p", 0.9),
|
||||
top_k = cmd.get("top_k", 40),
|
||||
min_p = cmd.get("min_p", 0.0),
|
||||
max_new_tokens = cmd.get("max_new_tokens", 512),
|
||||
repetition_penalty = cmd.get("repetition_penalty", 1.1),
|
||||
cancel_event = cancel_event,
|
||||
)
|
||||
|
||||
logger.info("Starting audio input generation for request_id=%s", request_id)
|
||||
|
||||
for text_chunk in generator:
|
||||
if cancel_event.is_set():
|
||||
logger.info("Audio input generation cancelled for request %s", request_id)
|
||||
logger.info(
|
||||
"Audio input generation cancelled for request %s", request_id
|
||||
)
|
||||
break
|
||||
|
||||
_send_response(resp_queue, {
|
||||
"type": "token",
|
||||
"request_id": request_id,
|
||||
"text": text_chunk,
|
||||
"ts": time.time(),
|
||||
})
|
||||
_send_response(
|
||||
resp_queue,
|
||||
{
|
||||
"type": "token",
|
||||
"request_id": request_id,
|
||||
"text": text_chunk,
|
||||
"ts": time.time(),
|
||||
},
|
||||
)
|
||||
|
||||
_send_response(resp_queue, {
|
||||
"type": "gen_done",
|
||||
"request_id": request_id,
|
||||
"ts": time.time(),
|
||||
})
|
||||
_send_response(
|
||||
resp_queue,
|
||||
{
|
||||
"type": "gen_done",
|
||||
"request_id": request_id,
|
||||
"ts": time.time(),
|
||||
},
|
||||
)
|
||||
logger.info("Finished audio input generation for request_id=%s", request_id)
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("Audio input generation error: %s", exc, exc_info=True)
|
||||
_send_response(resp_queue, {
|
||||
"type": "gen_error",
|
||||
"request_id": request_id,
|
||||
"error": str(exc),
|
||||
"stack": traceback.format_exc(limit=20),
|
||||
"ts": time.time(),
|
||||
})
|
||||
logger.error("Audio input generation error: %s", exc, exc_info = True)
|
||||
_send_response(
|
||||
resp_queue,
|
||||
{
|
||||
"type": "gen_error",
|
||||
"request_id": request_id,
|
||||
"error": str(exc),
|
||||
"stack": traceback.format_exc(limit = 20),
|
||||
"ts": time.time(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _handle_unload(backend, cmd: dict, resp_queue: Any) -> None:
|
||||
|
|
@ -397,19 +467,25 @@ def _handle_unload(backend, cmd: dict, resp_queue: Any) -> None:
|
|||
elif backend.active_model_name:
|
||||
backend.unload_model(backend.active_model_name)
|
||||
|
||||
_send_response(resp_queue, {
|
||||
"type": "unloaded",
|
||||
"model_name": model_name,
|
||||
"ts": time.time(),
|
||||
})
|
||||
_send_response(
|
||||
resp_queue,
|
||||
{
|
||||
"type": "unloaded",
|
||||
"model_name": model_name,
|
||||
"ts": time.time(),
|
||||
},
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error("Unload error: %s", exc)
|
||||
_send_response(resp_queue, {
|
||||
"type": "unloaded",
|
||||
"model_name": model_name,
|
||||
"error": str(exc),
|
||||
"ts": time.time(),
|
||||
})
|
||||
_send_response(
|
||||
resp_queue,
|
||||
{
|
||||
"type": "unloaded",
|
||||
"model_name": model_name,
|
||||
"error": str(exc),
|
||||
"ts": time.time(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def run_inference_process(
|
||||
|
|
@ -428,16 +504,19 @@ def run_inference_process(
|
|||
config: Initial configuration dict with model info.
|
||||
"""
|
||||
os.environ["TOKENIZERS_PARALLELISM"] = "false"
|
||||
os.environ["PYTHONWARNINGS"] = "ignore" # Suppress warnings at C-level before imports
|
||||
os.environ["PYTHONWARNINGS"] = (
|
||||
"ignore" # Suppress warnings at C-level before imports
|
||||
)
|
||||
|
||||
import warnings
|
||||
from loggers.config import LogConfig
|
||||
|
||||
if os.getenv("ENVIRONMENT_TYPE", "production") == "production":
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
LogConfig.setup_logging(
|
||||
service_name="unsloth-studio-inference-worker",
|
||||
env=os.getenv("ENVIRONMENT_TYPE", "production"),
|
||||
service_name = "unsloth-studio-inference-worker",
|
||||
env = os.getenv("ENVIRONMENT_TYPE", "production"),
|
||||
)
|
||||
|
||||
model_name = config["model_name"]
|
||||
|
|
@ -446,18 +525,22 @@ def run_inference_process(
|
|||
try:
|
||||
_activate_transformers_version(model_name)
|
||||
except Exception as exc:
|
||||
_send_response(resp_queue, {
|
||||
"type": "error",
|
||||
"error": f"Failed to activate transformers version: {exc}",
|
||||
"stack": traceback.format_exc(limit=20),
|
||||
"ts": time.time(),
|
||||
})
|
||||
_send_response(
|
||||
resp_queue,
|
||||
{
|
||||
"type": "error",
|
||||
"error": f"Failed to activate transformers version: {exc}",
|
||||
"stack": traceback.format_exc(limit = 20),
|
||||
"ts": time.time(),
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
# ── 1b. On Windows, check Triton availability (must be before import torch) ──
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
import triton # noqa: F401
|
||||
|
||||
logger.info("Triton available — torch.compile enabled")
|
||||
except ImportError:
|
||||
os.environ["TORCHDYNAMO_DISABLE"] = "1"
|
||||
|
|
@ -468,11 +551,14 @@ def run_inference_process(
|
|||
|
||||
# ── 2. Import ML libraries (fresh in this clean process) ──
|
||||
try:
|
||||
_send_response(resp_queue, {
|
||||
"type": "status",
|
||||
"message": "Importing ML libraries...",
|
||||
"ts": time.time(),
|
||||
})
|
||||
_send_response(
|
||||
resp_queue,
|
||||
{
|
||||
"type": "status",
|
||||
"message": "Importing ML libraries...",
|
||||
"ts": time.time(),
|
||||
},
|
||||
)
|
||||
|
||||
backend_path = str(Path(__file__).resolve().parent.parent.parent)
|
||||
if backend_path not in sys.path:
|
||||
|
|
@ -481,36 +567,46 @@ def run_inference_process(
|
|||
from core.inference.inference import InferenceBackend
|
||||
|
||||
import transformers
|
||||
|
||||
logger.info("Subprocess loaded transformers %s", transformers.__version__)
|
||||
|
||||
except Exception as exc:
|
||||
_send_response(resp_queue, {
|
||||
"type": "error",
|
||||
"error": f"Failed to import ML libraries: {exc}",
|
||||
"stack": traceback.format_exc(limit=20),
|
||||
"ts": time.time(),
|
||||
})
|
||||
_send_response(
|
||||
resp_queue,
|
||||
{
|
||||
"type": "error",
|
||||
"error": f"Failed to import ML libraries: {exc}",
|
||||
"stack": traceback.format_exc(limit = 20),
|
||||
"ts": time.time(),
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
# ── 3. Create inference backend and load initial model ──
|
||||
try:
|
||||
backend = InferenceBackend()
|
||||
|
||||
_send_response(resp_queue, {
|
||||
"type": "status",
|
||||
"message": "Loading model...",
|
||||
"ts": time.time(),
|
||||
})
|
||||
_send_response(
|
||||
resp_queue,
|
||||
{
|
||||
"type": "status",
|
||||
"message": "Loading model...",
|
||||
"ts": time.time(),
|
||||
},
|
||||
)
|
||||
|
||||
_handle_load(backend, config, resp_queue)
|
||||
|
||||
except Exception as exc:
|
||||
_send_response(resp_queue, {
|
||||
"type": "error",
|
||||
"error": f"Failed to initialize inference backend: {exc}",
|
||||
"stack": traceback.format_exc(limit=20),
|
||||
"ts": time.time(),
|
||||
})
|
||||
_send_response(
|
||||
resp_queue,
|
||||
{
|
||||
"type": "error",
|
||||
"error": f"Failed to initialize inference backend: {exc}",
|
||||
"stack": traceback.format_exc(limit = 20),
|
||||
"ts": time.time(),
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
# ── 4. Command loop — process commands until shutdown ──
|
||||
|
|
@ -520,7 +616,7 @@ def run_inference_process(
|
|||
|
||||
while True:
|
||||
try:
|
||||
cmd = cmd_queue.get(timeout=1.0)
|
||||
cmd = cmd_queue.get(timeout = 1.0)
|
||||
except _queue.Empty:
|
||||
continue
|
||||
except (EOFError, OSError):
|
||||
|
|
@ -564,26 +660,32 @@ def run_inference_process(
|
|||
elif cmd_type == "reset":
|
||||
cancel_event.set()
|
||||
backend.reset_generation_state()
|
||||
_send_response(resp_queue, {
|
||||
"type": "reset_ack",
|
||||
"ts": time.time(),
|
||||
})
|
||||
_send_response(
|
||||
resp_queue,
|
||||
{
|
||||
"type": "reset_ack",
|
||||
"ts": time.time(),
|
||||
},
|
||||
)
|
||||
|
||||
elif cmd_type == "status":
|
||||
# Return current status
|
||||
_send_response(resp_queue, {
|
||||
"type": "status_response",
|
||||
"active_model": backend.active_model_name,
|
||||
"models": {
|
||||
name: {
|
||||
"is_vision": info.get("is_vision", False),
|
||||
"is_lora": info.get("is_lora", False),
|
||||
}
|
||||
for name, info in backend.models.items()
|
||||
_send_response(
|
||||
resp_queue,
|
||||
{
|
||||
"type": "status_response",
|
||||
"active_model": backend.active_model_name,
|
||||
"models": {
|
||||
name: {
|
||||
"is_vision": info.get("is_vision", False),
|
||||
"is_lora": info.get("is_lora", False),
|
||||
}
|
||||
for name, info in backend.models.items()
|
||||
},
|
||||
"loading": list(backend.loading_models),
|
||||
"ts": time.time(),
|
||||
},
|
||||
"loading": list(backend.loading_models),
|
||||
"ts": time.time(),
|
||||
})
|
||||
)
|
||||
|
||||
elif cmd_type == "shutdown":
|
||||
logger.info("Shutdown command received, exiting")
|
||||
|
|
@ -593,25 +695,36 @@ def run_inference_process(
|
|||
backend.unload_model(model_name)
|
||||
except Exception:
|
||||
pass
|
||||
_send_response(resp_queue, {
|
||||
"type": "shutdown_ack",
|
||||
"ts": time.time(),
|
||||
})
|
||||
_send_response(
|
||||
resp_queue,
|
||||
{
|
||||
"type": "shutdown_ack",
|
||||
"ts": time.time(),
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
else:
|
||||
logger.warning("Unknown command type: %s", cmd_type)
|
||||
_send_response(resp_queue, {
|
||||
"type": "error",
|
||||
"error": f"Unknown command type: {cmd_type}",
|
||||
"ts": time.time(),
|
||||
})
|
||||
_send_response(
|
||||
resp_queue,
|
||||
{
|
||||
"type": "error",
|
||||
"error": f"Unknown command type: {cmd_type}",
|
||||
"ts": time.time(),
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("Error handling command '%s': %s", cmd_type, exc, exc_info=True)
|
||||
_send_response(resp_queue, {
|
||||
"type": "error",
|
||||
"error": f"Command '{cmd_type}' failed: {exc}",
|
||||
"stack": traceback.format_exc(limit=20),
|
||||
"ts": time.time(),
|
||||
})
|
||||
logger.error(
|
||||
"Error handling command '%s': %s", cmd_type, exc, exc_info = True
|
||||
)
|
||||
_send_response(
|
||||
resp_queue,
|
||||
{
|
||||
"type": "error",
|
||||
"error": f"Command '{cmd_type}' failed: {exc}",
|
||||
"stack": traceback.format_exc(limit = 20),
|
||||
"ts": time.time(),
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,10 +4,11 @@
|
|||
"""
|
||||
Training submodule - Training backends and trainer classes
|
||||
"""
|
||||
|
||||
from .training import TrainingBackend, TrainingProgress, get_training_backend
|
||||
|
||||
__all__ = [
|
||||
'TrainingProgress',
|
||||
'TrainingBackend',
|
||||
'get_training_backend',
|
||||
"TrainingProgress",
|
||||
"TrainingBackend",
|
||||
"get_training_backend",
|
||||
]
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -13,6 +13,7 @@ worker's mp.Queue, and exposes the same API surface to routes/training.py.
|
|||
|
||||
Pattern follows core/data_recipe/jobs/manager.py.
|
||||
"""
|
||||
|
||||
import math
|
||||
import multiprocessing as mp
|
||||
import queue
|
||||
|
|
@ -39,6 +40,7 @@ PLOT_HEIGHT = 3.5
|
|||
class TrainingProgress:
|
||||
"""Mirror of trainer.TrainingProgress — kept here so the parent process
|
||||
never needs to import the heavy ML modules."""
|
||||
|
||||
epoch: float = 0
|
||||
step: int = 0
|
||||
total_steps: int = 0
|
||||
|
|
@ -109,7 +111,7 @@ class TrainingBackend:
|
|||
# Join prior pump thread to prevent it from consuming events
|
||||
# from the new job's queue (it reads self._event_queue dynamically).
|
||||
if self._pump_thread is not None and self._pump_thread.is_alive():
|
||||
self._pump_thread.join(timeout=5.0)
|
||||
self._pump_thread.join(timeout = 5.0)
|
||||
if self._pump_thread.is_alive():
|
||||
logger.warning("Previous pump thread did not exit within 5s")
|
||||
self._pump_thread = None
|
||||
|
|
@ -117,7 +119,9 @@ class TrainingBackend:
|
|||
# Reset state
|
||||
self._should_stop = False
|
||||
self._cancel_requested = False
|
||||
self._progress = TrainingProgress(is_training=True, status_message="Initializing training...")
|
||||
self._progress = TrainingProgress(
|
||||
is_training = True, status_message = "Initializing training..."
|
||||
)
|
||||
self.loss_history.clear()
|
||||
self.lr_history.clear()
|
||||
self.step_history.clear()
|
||||
|
|
@ -172,7 +176,9 @@ class TrainingBackend:
|
|||
"train_on_completions": kwargs.get("train_on_completions", False),
|
||||
"finetune_vision_layers": kwargs.get("finetune_vision_layers", True),
|
||||
"finetune_language_layers": kwargs.get("finetune_language_layers", True),
|
||||
"finetune_attention_modules": kwargs.get("finetune_attention_modules", True),
|
||||
"finetune_attention_modules": kwargs.get(
|
||||
"finetune_attention_modules", True
|
||||
),
|
||||
"finetune_mlp_modules": kwargs.get("finetune_mlp_modules", True),
|
||||
"enable_wandb": kwargs.get("enable_wandb", False),
|
||||
"wandb_token": kwargs.get("wandb_token"),
|
||||
|
|
@ -193,19 +199,19 @@ class TrainingBackend:
|
|||
self._stop_queue = _CTX.Queue()
|
||||
|
||||
self._proc = _CTX.Process(
|
||||
target=run_training_process,
|
||||
kwargs={
|
||||
target = run_training_process,
|
||||
kwargs = {
|
||||
"event_queue": self._event_queue,
|
||||
"stop_queue": self._stop_queue,
|
||||
"config": config,
|
||||
},
|
||||
daemon=True,
|
||||
daemon = True,
|
||||
)
|
||||
self._proc.start()
|
||||
logger.info("Training subprocess started (pid=%s)", self._proc.pid)
|
||||
|
||||
# Start event pump thread
|
||||
self._pump_thread = threading.Thread(target=self._pump_loop, daemon=True)
|
||||
self._pump_thread = threading.Thread(target = self._pump_loop, daemon = True)
|
||||
self._pump_thread.start()
|
||||
|
||||
return True
|
||||
|
|
@ -224,7 +230,8 @@ class TrainingBackend:
|
|||
# Update progress immediately for responsive UI
|
||||
self._progress.status_message = (
|
||||
"Stopping training and saving checkpoint..."
|
||||
if save else "Cancelling training..."
|
||||
if save
|
||||
else "Cancelling training..."
|
||||
)
|
||||
return True
|
||||
|
||||
|
|
@ -232,15 +239,17 @@ class TrainingBackend:
|
|||
"""Force-kill the training subprocess so state can be reset immediately."""
|
||||
with self._lock:
|
||||
if self._proc is not None and self._proc.is_alive():
|
||||
logger.info("Force-terminating training subprocess (pid=%s)", self._proc.pid)
|
||||
logger.info(
|
||||
"Force-terminating training subprocess (pid=%s)", self._proc.pid
|
||||
)
|
||||
self._proc.terminate()
|
||||
proc = self._proc
|
||||
|
||||
if proc is not None:
|
||||
proc.join(timeout=5.0)
|
||||
proc.join(timeout = 5.0)
|
||||
if proc.is_alive():
|
||||
proc.kill()
|
||||
proc.join(timeout=2.0)
|
||||
proc.join(timeout = 2.0)
|
||||
|
||||
def is_training_active(self) -> bool:
|
||||
"""Check if training is currently active."""
|
||||
|
|
@ -262,9 +271,29 @@ class TrainingBackend:
|
|||
|
||||
# Check status message for activity indicators
|
||||
status_lower = (p.status_message or "").lower()
|
||||
if any(k in status_lower for k in ["cancelled", "canceled", "stopped", "completed", "ready to train"]):
|
||||
if any(
|
||||
k in status_lower
|
||||
for k in [
|
||||
"cancelled",
|
||||
"canceled",
|
||||
"stopped",
|
||||
"completed",
|
||||
"ready to train",
|
||||
]
|
||||
):
|
||||
return False
|
||||
if any(k in status_lower for k in ["loading", "preparing", "training", "configuring", "tokenizing", "starting", "importing"]):
|
||||
if any(
|
||||
k in status_lower
|
||||
for k in [
|
||||
"loading",
|
||||
"preparing",
|
||||
"training",
|
||||
"configuring",
|
||||
"tokenizing",
|
||||
"starting",
|
||||
"importing",
|
||||
]
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
|
@ -282,7 +311,7 @@ class TrainingBackend:
|
|||
|
||||
def refresh_plot_for_theme(self, theme: str) -> Optional[plt.Figure]:
|
||||
"""Refresh plot with new theme."""
|
||||
if theme and isinstance(theme, str) and theme in ['light', 'dark']:
|
||||
if theme and isinstance(theme, str) and theme in ["light", "dark"]:
|
||||
self.current_theme = theme
|
||||
if self.loss_history:
|
||||
with self._lock:
|
||||
|
|
@ -296,6 +325,7 @@ class TrainingBackend:
|
|||
|
||||
class _TrainerShim:
|
||||
"""Minimal shim so routes that access backend.trainer.* still work."""
|
||||
|
||||
def __init__(self, backend: "TrainingBackend"):
|
||||
self._backend = backend
|
||||
self.should_stop = False
|
||||
|
|
@ -333,7 +363,7 @@ class TrainingBackend:
|
|||
return
|
||||
|
||||
# Try to read an event
|
||||
event = self._read_queue(self._event_queue, timeout_sec=0.25)
|
||||
event = self._read_queue(self._event_queue, timeout_sec = 0.25)
|
||||
if event is not None:
|
||||
self._handle_event(event)
|
||||
continue
|
||||
|
|
@ -354,7 +384,10 @@ class TrainingBackend:
|
|||
self._progress.status_message = "Training stopped."
|
||||
else:
|
||||
self._progress.is_training = False
|
||||
self._progress.error = self._progress.error or "Training process exited unexpectedly"
|
||||
self._progress.error = (
|
||||
self._progress.error
|
||||
or "Training process exited unexpectedly"
|
||||
)
|
||||
return
|
||||
|
||||
def _handle_event(self, event: dict) -> None:
|
||||
|
|
@ -366,8 +399,12 @@ class TrainingBackend:
|
|||
self._progress.step = event.get("step", self._progress.step)
|
||||
self._progress.epoch = event.get("epoch", self._progress.epoch)
|
||||
self._progress.loss = event.get("loss", self._progress.loss)
|
||||
self._progress.learning_rate = event.get("learning_rate", self._progress.learning_rate)
|
||||
self._progress.total_steps = event.get("total_steps", self._progress.total_steps)
|
||||
self._progress.learning_rate = event.get(
|
||||
"learning_rate", self._progress.learning_rate
|
||||
)
|
||||
self._progress.total_steps = event.get(
|
||||
"total_steps", self._progress.total_steps
|
||||
)
|
||||
self._progress.elapsed_seconds = event.get("elapsed_seconds")
|
||||
self._progress.eta_seconds = event.get("eta_seconds")
|
||||
self._progress.grad_norm = event.get("grad_norm")
|
||||
|
|
@ -428,7 +465,7 @@ class TrainingBackend:
|
|||
@staticmethod
|
||||
def _read_queue(q: Any, timeout_sec: float) -> Optional[dict]:
|
||||
try:
|
||||
return q.get(timeout=timeout_sec)
|
||||
return q.get(timeout = timeout_sec)
|
||||
except queue.Empty:
|
||||
return None
|
||||
except (EOFError, OSError, ValueError):
|
||||
|
|
@ -449,28 +486,30 @@ class TrainingBackend:
|
|||
# Plot generation (unchanged from original)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _create_loss_plot(self, progress: TrainingProgress, theme: str = "light") -> plt.Figure:
|
||||
def _create_loss_plot(
|
||||
self, progress: TrainingProgress, theme: str = "light"
|
||||
) -> plt.Figure:
|
||||
"""Create training loss plot with theme-aware styling."""
|
||||
plt.close('all')
|
||||
plt.close("all")
|
||||
|
||||
LIGHT_STYLE = {
|
||||
"facecolor": "#ffffff",
|
||||
"grid_color": "#d1d5db",
|
||||
"line": "#16b88a",
|
||||
"text": "#1f2937",
|
||||
"empty_text": "#6b7280"
|
||||
"empty_text": "#6b7280",
|
||||
}
|
||||
DARK_STYLE = {
|
||||
"facecolor": "#292929",
|
||||
"grid_color": "#404040",
|
||||
"line": "#4ade80",
|
||||
"text": "#e5e7eb",
|
||||
"empty_text": "#9ca3af"
|
||||
"empty_text": "#9ca3af",
|
||||
}
|
||||
|
||||
style = LIGHT_STYLE if theme == "light" else DARK_STYLE
|
||||
|
||||
fig, ax = plt.subplots(figsize=(PLOT_WIDTH, PLOT_HEIGHT))
|
||||
fig, ax = plt.subplots(figsize = (PLOT_WIDTH, PLOT_HEIGHT))
|
||||
fig.patch.set_facecolor(style["facecolor"])
|
||||
ax.set_facecolor(style["facecolor"])
|
||||
|
||||
|
|
@ -478,8 +517,15 @@ class TrainingBackend:
|
|||
steps = self.step_history
|
||||
losses = self.loss_history
|
||||
scatter_color = "#60a5fa"
|
||||
ax.scatter(steps, losses, s=16, alpha=0.6, color=scatter_color,
|
||||
linewidths=0, label="Training Loss (raw)")
|
||||
ax.scatter(
|
||||
steps,
|
||||
losses,
|
||||
s = 16,
|
||||
alpha = 0.6,
|
||||
color = scatter_color,
|
||||
linewidths = 0,
|
||||
label = "Training Loss (raw)",
|
||||
)
|
||||
|
||||
MA_WINDOW = 20
|
||||
window = min(MA_WINDOW, len(losses))
|
||||
|
|
@ -495,15 +541,21 @@ class TrainingBackend:
|
|||
denom = i - start + 1
|
||||
ma.append((cumsum[i + 1] - cumsum[start]) / denom)
|
||||
|
||||
ax.plot(steps, ma, color=style["line"], linewidth=2.5, alpha=0.95,
|
||||
label=f"Moving Avg ({ma[-1]:.4f})")
|
||||
ax.plot(
|
||||
steps,
|
||||
ma,
|
||||
color = style["line"],
|
||||
linewidth = 2.5,
|
||||
alpha = 0.95,
|
||||
label = f"Moving Avg ({ma[-1]:.4f})",
|
||||
)
|
||||
|
||||
leg = ax.legend(frameon=False, fontsize=9)
|
||||
leg = ax.legend(frameon = False, fontsize = 9)
|
||||
for t in leg.get_texts():
|
||||
t.set_color(style["text"])
|
||||
|
||||
ax.set_xlabel('Steps', fontsize=10, color=style["text"])
|
||||
ax.set_ylabel('Loss', fontsize=10, color=style["text"])
|
||||
ax.set_xlabel("Steps", fontsize = 10, color = style["text"])
|
||||
ax.set_ylabel("Loss", fontsize = 10, color = style["text"])
|
||||
|
||||
if progress.error:
|
||||
title = f"Error: {progress.error}"
|
||||
|
|
@ -516,17 +568,31 @@ class TrainingBackend:
|
|||
else:
|
||||
title = "Training Loss"
|
||||
|
||||
ax.set_title(title, fontsize=11, fontweight='bold', pad=10, color=style["text"])
|
||||
ax.grid(True, alpha=0.4, linestyle='--', color=style["grid_color"])
|
||||
ax.tick_params(colors=style["text"], which='both')
|
||||
ax.spines['top'].set_visible(False)
|
||||
ax.spines['right'].set_visible(False)
|
||||
ax.spines['bottom'].set_color(style["text"])
|
||||
ax.spines['left'].set_color(style["text"])
|
||||
ax.set_title(
|
||||
title, fontsize = 11, fontweight = "bold", pad = 10, color = style["text"]
|
||||
)
|
||||
ax.grid(True, alpha = 0.4, linestyle = "--", color = style["grid_color"])
|
||||
ax.tick_params(colors = style["text"], which = "both")
|
||||
ax.spines["top"].set_visible(False)
|
||||
ax.spines["right"].set_visible(False)
|
||||
ax.spines["bottom"].set_color(style["text"])
|
||||
ax.spines["left"].set_color(style["text"])
|
||||
else:
|
||||
display_msg = progress.status_message if progress.status_message else 'Waiting for training data...'
|
||||
ax.text(0.5, 0.5, display_msg, ha='center', va='center', fontsize=16,
|
||||
color=style["empty_text"], transform=ax.transAxes)
|
||||
display_msg = (
|
||||
progress.status_message
|
||||
if progress.status_message
|
||||
else "Waiting for training data..."
|
||||
)
|
||||
ax.text(
|
||||
0.5,
|
||||
0.5,
|
||||
display_msg,
|
||||
ha = "center",
|
||||
va = "center",
|
||||
fontsize = 16,
|
||||
color = style["empty_text"],
|
||||
transform = ax.transAxes,
|
||||
)
|
||||
ax.set_xticks([])
|
||||
ax.set_yticks([])
|
||||
for spine in ax.spines.values():
|
||||
|
|
@ -544,7 +610,8 @@ class TrainingBackend:
|
|||
"""
|
||||
logger.info(
|
||||
"_transfer_to_inference_backend: subprocess training — "
|
||||
"model must be loaded from disk (output_dir=%s)", self._output_dir
|
||||
"model must be loaded from disk (output_dir=%s)",
|
||||
self._output_dir,
|
||||
)
|
||||
return False
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ solving the transformers version-switching problem completely.
|
|||
|
||||
Pattern follows core/data_recipe/jobs/worker.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import structlog
|
||||
|
|
@ -39,7 +40,9 @@ def _activate_transformers_version(model_name: str) -> None:
|
|||
|
||||
resolved = _resolve_base_model(model_name)
|
||||
if needs_transformers_5(resolved):
|
||||
venv_t5 = os.path.join(os.path.expanduser("~"), ".unsloth", "studio", ".venv_t5")
|
||||
venv_t5 = os.path.join(
|
||||
os.path.expanduser("~"), ".unsloth", "studio", ".venv_t5"
|
||||
)
|
||||
if os.path.isdir(venv_t5):
|
||||
sys.path.insert(0, venv_t5)
|
||||
logger.info("Activated transformers 5.x from %s", venv_t5)
|
||||
|
|
@ -47,16 +50,35 @@ def _activate_transformers_version(model_name: str) -> None:
|
|||
# Fallback: pip install at runtime (slower, ~10-15s)
|
||||
logger.warning(".venv_t5 not found at %s — installing at runtime", venv_t5)
|
||||
import subprocess as sp
|
||||
os.makedirs(venv_t5, exist_ok=True)
|
||||
|
||||
os.makedirs(venv_t5, exist_ok = True)
|
||||
r1 = sp.run(
|
||||
[sys.executable, "-m", "pip", "install", "--target", venv_t5,
|
||||
"--no-deps", "transformers==5.2.0"],
|
||||
stdout=sp.PIPE, stderr=sp.STDOUT,
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"--target",
|
||||
venv_t5,
|
||||
"--no-deps",
|
||||
"transformers==5.2.0",
|
||||
],
|
||||
stdout = sp.PIPE,
|
||||
stderr = sp.STDOUT,
|
||||
)
|
||||
r2 = sp.run(
|
||||
[sys.executable, "-m", "pip", "install", "--target", venv_t5,
|
||||
"--no-deps", "huggingface_hub==1.3.0"],
|
||||
stdout=sp.PIPE, stderr=sp.STDOUT,
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"--target",
|
||||
venv_t5,
|
||||
"--no-deps",
|
||||
"huggingface_hub==1.3.0",
|
||||
],
|
||||
stdout = sp.PIPE,
|
||||
stderr = sp.STDOUT,
|
||||
)
|
||||
if r1.returncode != 0 or r2.returncode != 0:
|
||||
raise RuntimeError(
|
||||
|
|
@ -85,16 +107,19 @@ def run_training_process(
|
|||
config: Training configuration dict with all parameters.
|
||||
"""
|
||||
os.environ["TOKENIZERS_PARALLELISM"] = "false"
|
||||
os.environ["PYTHONWARNINGS"] = "ignore" # Suppress warnings at C-level before imports
|
||||
os.environ["PYTHONWARNINGS"] = (
|
||||
"ignore" # Suppress warnings at C-level before imports
|
||||
)
|
||||
|
||||
import warnings
|
||||
from loggers.config import LogConfig
|
||||
|
||||
if os.getenv("ENVIRONMENT_TYPE", "production") == "production":
|
||||
warnings.filterwarnings("ignore")
|
||||
|
||||
|
||||
LogConfig.setup_logging(
|
||||
service_name="unsloth-studio-training-worker",
|
||||
env=os.getenv("ENVIRONMENT_TYPE", "production"),
|
||||
service_name = "unsloth-studio-training-worker",
|
||||
env = os.getenv("ENVIRONMENT_TYPE", "production"),
|
||||
)
|
||||
|
||||
model_name = config["model_name"]
|
||||
|
|
@ -103,18 +128,21 @@ def run_training_process(
|
|||
try:
|
||||
_activate_transformers_version(model_name)
|
||||
except Exception as exc:
|
||||
event_queue.put({
|
||||
"type": "error",
|
||||
"error": f"Failed to activate transformers version: {exc}",
|
||||
"stack": traceback.format_exc(limit=20),
|
||||
"ts": time.time(),
|
||||
})
|
||||
event_queue.put(
|
||||
{
|
||||
"type": "error",
|
||||
"error": f"Failed to activate transformers version: {exc}",
|
||||
"stack": traceback.format_exc(limit = 20),
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
# ── 1b. On Windows, check Triton availability (must be before import torch) ──
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
import triton # noqa: F401
|
||||
|
||||
logger.info("Triton available — torch.compile enabled")
|
||||
except ImportError:
|
||||
os.environ["TORCHDYNAMO_DISABLE"] = "1"
|
||||
|
|
@ -132,17 +160,25 @@ def run_training_process(
|
|||
sys.path.insert(0, backend_path)
|
||||
|
||||
from core.training.trainer import UnslothTrainer, TrainingProgress
|
||||
from utils.paths import ensure_dir, resolve_output_dir, resolve_tensorboard_dir, datasets_root
|
||||
from utils.paths import (
|
||||
ensure_dir,
|
||||
resolve_output_dir,
|
||||
resolve_tensorboard_dir,
|
||||
datasets_root,
|
||||
)
|
||||
|
||||
import transformers
|
||||
|
||||
logger.info("Subprocess loaded transformers %s", transformers.__version__)
|
||||
except Exception as exc:
|
||||
event_queue.put({
|
||||
"type": "error",
|
||||
"error": f"Failed to import ML libraries: {exc}",
|
||||
"stack": traceback.format_exc(limit=20),
|
||||
"ts": time.time(),
|
||||
})
|
||||
event_queue.put(
|
||||
{
|
||||
"type": "error",
|
||||
"error": f"Failed to import ML libraries: {exc}",
|
||||
"stack": traceback.format_exc(limit = 20),
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
# ── 2b. EMBEDDING MODEL FAST-PATH ──
|
||||
|
|
@ -153,12 +189,14 @@ def run_training_process(
|
|||
try:
|
||||
_run_embedding_training(event_queue, stop_queue, config)
|
||||
except Exception as exc:
|
||||
event_queue.put({
|
||||
"type": "error",
|
||||
"error": str(exc),
|
||||
"stack": traceback.format_exc(limit=20),
|
||||
"ts": time.time(),
|
||||
})
|
||||
event_queue.put(
|
||||
{
|
||||
"type": "error",
|
||||
"error": str(exc),
|
||||
"stack": traceback.format_exc(limit = 20),
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
# ── 3. Create a fresh trainer instance ──
|
||||
|
|
@ -169,21 +207,23 @@ def run_training_process(
|
|||
has_train_loss = progress.step >= 0 and progress.loss > 0
|
||||
has_eval_loss = progress.eval_loss is not None
|
||||
if has_train_loss or has_eval_loss:
|
||||
event_queue.put({
|
||||
"type": "progress",
|
||||
"step": progress.step,
|
||||
"epoch": progress.epoch,
|
||||
"loss": progress.loss,
|
||||
"learning_rate": progress.learning_rate,
|
||||
"total_steps": progress.total_steps,
|
||||
"elapsed_seconds": progress.elapsed_seconds,
|
||||
"eta_seconds": progress.eta_seconds,
|
||||
"grad_norm": progress.grad_norm,
|
||||
"num_tokens": progress.num_tokens,
|
||||
"eval_loss": progress.eval_loss,
|
||||
"status_message": progress.status_message,
|
||||
"ts": time.time(),
|
||||
})
|
||||
event_queue.put(
|
||||
{
|
||||
"type": "progress",
|
||||
"step": progress.step,
|
||||
"epoch": progress.epoch,
|
||||
"loss": progress.loss,
|
||||
"learning_rate": progress.learning_rate,
|
||||
"total_steps": progress.total_steps,
|
||||
"elapsed_seconds": progress.elapsed_seconds,
|
||||
"eta_seconds": progress.eta_seconds,
|
||||
"grad_norm": progress.grad_norm,
|
||||
"num_tokens": progress.num_tokens,
|
||||
"eval_loss": progress.eval_loss,
|
||||
"status_message": progress.status_message,
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
if progress.status_message:
|
||||
_send_status(event_queue, progress.status_message)
|
||||
|
||||
|
|
@ -196,7 +236,7 @@ def run_training_process(
|
|||
def _poll_stop():
|
||||
while True:
|
||||
try:
|
||||
msg = stop_queue.get(timeout=1.0)
|
||||
msg = stop_queue.get(timeout = 1.0)
|
||||
if msg and msg.get("type") == "stop":
|
||||
save = msg.get("save", True)
|
||||
trainer.should_stop = True
|
||||
|
|
@ -208,7 +248,7 @@ def run_training_process(
|
|||
except (EOFError, OSError):
|
||||
return
|
||||
|
||||
stop_thread = threading.Thread(target=_poll_stop, daemon=True)
|
||||
stop_thread = threading.Thread(target = _poll_stop, daemon = True)
|
||||
stop_thread.start()
|
||||
|
||||
# ── 4. Execute the training pipeline ──
|
||||
|
|
@ -222,12 +262,12 @@ def run_training_process(
|
|||
# ── 4a. Lightweight detection + tokenizer (no VRAM) ──
|
||||
_send_status(event_queue, "Detecting model type...")
|
||||
trainer.pre_detect_and_load_tokenizer(
|
||||
model_name=model_name,
|
||||
max_seq_length=config["max_seq_length"],
|
||||
hf_token=hf_token,
|
||||
is_dataset_image=config.get("is_dataset_image", False),
|
||||
is_dataset_audio=config.get("is_dataset_audio", False),
|
||||
trust_remote_code=config.get("trust_remote_code", False),
|
||||
model_name = model_name,
|
||||
max_seq_length = config["max_seq_length"],
|
||||
hf_token = hf_token,
|
||||
is_dataset_image = config.get("is_dataset_image", False),
|
||||
is_dataset_audio = config.get("is_dataset_audio", False),
|
||||
trust_remote_code = config.get("trust_remote_code", False),
|
||||
)
|
||||
if trainer.should_stop:
|
||||
event_queue.put({"type": "complete", "output_dir": None, "ts": time.time()})
|
||||
|
|
@ -237,16 +277,16 @@ def run_training_process(
|
|||
_send_status(event_queue, "Loading and formatting dataset...")
|
||||
hf_dataset = config.get("hf_dataset", "")
|
||||
dataset_result = trainer.load_and_format_dataset(
|
||||
dataset_source=hf_dataset if hf_dataset and hf_dataset.strip() else None,
|
||||
format_type=config.get("format_type", ""),
|
||||
local_datasets=config.get("local_datasets") or None,
|
||||
custom_format_mapping=config.get("custom_format_mapping"),
|
||||
subset=config.get("subset"),
|
||||
train_split=config.get("train_split", "train"),
|
||||
eval_split=config.get("eval_split"),
|
||||
eval_steps=config.get("eval_steps", 0.00),
|
||||
dataset_slice_start=config.get("dataset_slice_start"),
|
||||
dataset_slice_end=config.get("dataset_slice_end"),
|
||||
dataset_source = hf_dataset if hf_dataset and hf_dataset.strip() else None,
|
||||
format_type = config.get("format_type", ""),
|
||||
local_datasets = config.get("local_datasets") or None,
|
||||
custom_format_mapping = config.get("custom_format_mapping"),
|
||||
subset = config.get("subset"),
|
||||
train_split = config.get("train_split", "train"),
|
||||
eval_split = config.get("eval_split"),
|
||||
eval_steps = config.get("eval_steps", 0.00),
|
||||
dataset_slice_start = config.get("dataset_slice_start"),
|
||||
dataset_slice_end = config.get("dataset_slice_end"),
|
||||
)
|
||||
|
||||
if isinstance(dataset_result, tuple):
|
||||
|
|
@ -260,13 +300,19 @@ def run_training_process(
|
|||
# or a raw Dataset for audio paths
|
||||
try:
|
||||
ds = dataset["dataset"] if isinstance(dataset, dict) else dataset
|
||||
print(f"\n[DEBUG] Dataset loaded BEFORE model. type={type(ds).__name__}, len={len(ds)}", flush=True)
|
||||
print(f"[DEBUG] Columns: {ds.column_names}", flush=True)
|
||||
print(
|
||||
f"\n[DEBUG] Dataset loaded BEFORE model. type={type(ds).__name__}, len={len(ds)}",
|
||||
flush = True,
|
||||
)
|
||||
print(f"[DEBUG] Columns: {ds.column_names}", flush = True)
|
||||
sample = ds[0]
|
||||
preview = {k: str(v)[:300] for k, v in sample.items()}
|
||||
print(f"[DEBUG] First sample: {preview}\n", flush=True)
|
||||
print(f"[DEBUG] First sample: {preview}\n", flush = True)
|
||||
except Exception as e:
|
||||
print(f"[DEBUG] Could not preview first sample: {type(e).__name__}: {e}", flush=True)
|
||||
print(
|
||||
f"[DEBUG] Could not preview first sample: {type(e).__name__}: {e}",
|
||||
flush = True,
|
||||
)
|
||||
|
||||
# Disable eval if eval_steps <= 0
|
||||
eval_steps = config.get("eval_steps", 0.00)
|
||||
|
|
@ -276,88 +322,114 @@ def run_training_process(
|
|||
# Tell the parent process that eval is configured so the frontend
|
||||
# shows "Waiting for first evaluation step..." instead of "not configured"
|
||||
if eval_dataset is not None:
|
||||
event_queue.put({
|
||||
"type": "eval_configured",
|
||||
"ts": time.time(),
|
||||
})
|
||||
event_queue.put(
|
||||
{
|
||||
"type": "eval_configured",
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
|
||||
if dataset is None or trainer.should_stop:
|
||||
if trainer.should_stop:
|
||||
event_queue.put({"type": "complete", "output_dir": None, "ts": time.time()})
|
||||
event_queue.put(
|
||||
{"type": "complete", "output_dir": None, "ts": time.time()}
|
||||
)
|
||||
else:
|
||||
event_queue.put({
|
||||
"type": "error",
|
||||
"error": trainer.training_progress.error or "Failed to load dataset",
|
||||
"stack": "", "ts": time.time(),
|
||||
})
|
||||
event_queue.put(
|
||||
{
|
||||
"type": "error",
|
||||
"error": trainer.training_progress.error
|
||||
or "Failed to load dataset",
|
||||
"stack": "",
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
# ── 4c. Load training model (uses VRAM — dataset already formatted) ──
|
||||
_send_status(event_queue, "Loading model...")
|
||||
success = trainer.load_model(
|
||||
model_name=model_name,
|
||||
max_seq_length=config["max_seq_length"],
|
||||
load_in_4bit=config["load_in_4bit"],
|
||||
hf_token=hf_token,
|
||||
is_dataset_image=config.get("is_dataset_image", False),
|
||||
is_dataset_audio=config.get("is_dataset_audio", False),
|
||||
trust_remote_code=config.get("trust_remote_code", False),
|
||||
model_name = model_name,
|
||||
max_seq_length = config["max_seq_length"],
|
||||
load_in_4bit = config["load_in_4bit"],
|
||||
hf_token = hf_token,
|
||||
is_dataset_image = config.get("is_dataset_image", False),
|
||||
is_dataset_audio = config.get("is_dataset_audio", False),
|
||||
trust_remote_code = config.get("trust_remote_code", False),
|
||||
)
|
||||
if not success or trainer.should_stop:
|
||||
if trainer.should_stop:
|
||||
event_queue.put({"type": "complete", "output_dir": None, "ts": time.time()})
|
||||
event_queue.put(
|
||||
{"type": "complete", "output_dir": None, "ts": time.time()}
|
||||
)
|
||||
else:
|
||||
error_msg = trainer.training_progress.error or "Failed to load model"
|
||||
event_queue.put({
|
||||
"type": "error",
|
||||
"error": error_msg,
|
||||
"stack": "", "ts": time.time(),
|
||||
})
|
||||
event_queue.put(
|
||||
{
|
||||
"type": "error",
|
||||
"error": error_msg,
|
||||
"stack": "",
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
# ── 4d. Prepare model (LoRA or full finetuning) ──
|
||||
training_type = config.get("training_type", "LoRA/QLoRA")
|
||||
use_lora = (training_type == "LoRA/QLoRA")
|
||||
use_lora = training_type == "LoRA/QLoRA"
|
||||
if use_lora:
|
||||
_send_status(event_queue, "Configuring LoRA adapters...")
|
||||
success = trainer.prepare_model_for_training(
|
||||
use_lora=True,
|
||||
finetune_vision_layers=config.get("finetune_vision_layers", True),
|
||||
finetune_language_layers=config.get("finetune_language_layers", True),
|
||||
finetune_attention_modules=config.get("finetune_attention_modules", True),
|
||||
finetune_mlp_modules=config.get("finetune_mlp_modules", True),
|
||||
target_modules=config.get("target_modules"),
|
||||
lora_r=config.get("lora_r", 16),
|
||||
lora_alpha=config.get("lora_alpha", 16),
|
||||
lora_dropout=config.get("lora_dropout", 0.0),
|
||||
use_gradient_checkpointing=config.get("gradient_checkpointing", "unsloth"),
|
||||
use_rslora=config.get("use_rslora", False),
|
||||
use_loftq=config.get("use_loftq", False),
|
||||
use_lora = True,
|
||||
finetune_vision_layers = config.get("finetune_vision_layers", True),
|
||||
finetune_language_layers = config.get("finetune_language_layers", True),
|
||||
finetune_attention_modules = config.get(
|
||||
"finetune_attention_modules", True
|
||||
),
|
||||
finetune_mlp_modules = config.get("finetune_mlp_modules", True),
|
||||
target_modules = config.get("target_modules"),
|
||||
lora_r = config.get("lora_r", 16),
|
||||
lora_alpha = config.get("lora_alpha", 16),
|
||||
lora_dropout = config.get("lora_dropout", 0.0),
|
||||
use_gradient_checkpointing = config.get(
|
||||
"gradient_checkpointing", "unsloth"
|
||||
),
|
||||
use_rslora = config.get("use_rslora", False),
|
||||
use_loftq = config.get("use_loftq", False),
|
||||
)
|
||||
else:
|
||||
_send_status(event_queue, "Preparing model for full finetuning...")
|
||||
success = trainer.prepare_model_for_training(use_lora=False)
|
||||
success = trainer.prepare_model_for_training(use_lora = False)
|
||||
|
||||
if not success or trainer.should_stop:
|
||||
if trainer.should_stop:
|
||||
event_queue.put({"type": "complete", "output_dir": None, "ts": time.time()})
|
||||
event_queue.put(
|
||||
{"type": "complete", "output_dir": None, "ts": time.time()}
|
||||
)
|
||||
else:
|
||||
event_queue.put({
|
||||
"type": "error",
|
||||
"error": trainer.training_progress.error or "Failed to prepare model",
|
||||
"stack": "", "ts": time.time(),
|
||||
})
|
||||
event_queue.put(
|
||||
{
|
||||
"type": "error",
|
||||
"error": trainer.training_progress.error
|
||||
or "Failed to prepare model",
|
||||
"stack": "",
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
# Convert learning rate
|
||||
try:
|
||||
lr_value = float(config.get("learning_rate", "2e-4"))
|
||||
except ValueError:
|
||||
event_queue.put({
|
||||
"type": "error",
|
||||
"error": f"Invalid learning rate: {config.get('learning_rate')}",
|
||||
"stack": "", "ts": time.time(),
|
||||
})
|
||||
event_queue.put(
|
||||
{
|
||||
"type": "error",
|
||||
"error": f"Invalid learning rate: {config.get('learning_rate')}",
|
||||
"stack": "",
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
# Generate output dir
|
||||
|
|
@ -379,64 +451,72 @@ def run_training_process(
|
|||
|
||||
trainer._train_worker(
|
||||
dataset,
|
||||
output_dir=output_dir,
|
||||
num_epochs=config.get("num_epochs", 3),
|
||||
learning_rate=lr_value,
|
||||
batch_size=config.get("batch_size", 2),
|
||||
gradient_accumulation_steps=config.get("gradient_accumulation_steps", 4),
|
||||
warmup_steps=config.get("warmup_steps"),
|
||||
warmup_ratio=config.get("warmup_ratio"),
|
||||
max_steps=max_steps if max_steps and max_steps > 0 else 0,
|
||||
save_steps=save_steps if save_steps and save_steps > 0 else 0,
|
||||
weight_decay=config.get("weight_decay", 0.01),
|
||||
random_seed=config.get("random_seed", 3407),
|
||||
packing=config.get("packing", False),
|
||||
train_on_completions=config.get("train_on_completions", False),
|
||||
enable_wandb=config.get("enable_wandb", False),
|
||||
wandb_project=config.get("wandb_project", "unsloth-training"),
|
||||
wandb_token=config.get("wandb_token"),
|
||||
enable_tensorboard=config.get("enable_tensorboard", False),
|
||||
tensorboard_dir=tensorboard_dir,
|
||||
eval_dataset=eval_dataset,
|
||||
eval_steps=eval_steps,
|
||||
max_seq_length=config.get("max_seq_length", 2048),
|
||||
optim=config.get("optim", "adamw_8bit"),
|
||||
lr_scheduler_type=config.get("lr_scheduler_type", "linear"),
|
||||
output_dir = output_dir,
|
||||
num_epochs = config.get("num_epochs", 3),
|
||||
learning_rate = lr_value,
|
||||
batch_size = config.get("batch_size", 2),
|
||||
gradient_accumulation_steps = config.get("gradient_accumulation_steps", 4),
|
||||
warmup_steps = config.get("warmup_steps"),
|
||||
warmup_ratio = config.get("warmup_ratio"),
|
||||
max_steps = max_steps if max_steps and max_steps > 0 else 0,
|
||||
save_steps = save_steps if save_steps and save_steps > 0 else 0,
|
||||
weight_decay = config.get("weight_decay", 0.01),
|
||||
random_seed = config.get("random_seed", 3407),
|
||||
packing = config.get("packing", False),
|
||||
train_on_completions = config.get("train_on_completions", False),
|
||||
enable_wandb = config.get("enable_wandb", False),
|
||||
wandb_project = config.get("wandb_project", "unsloth-training"),
|
||||
wandb_token = config.get("wandb_token"),
|
||||
enable_tensorboard = config.get("enable_tensorboard", False),
|
||||
tensorboard_dir = tensorboard_dir,
|
||||
eval_dataset = eval_dataset,
|
||||
eval_steps = eval_steps,
|
||||
max_seq_length = config.get("max_seq_length", 2048),
|
||||
optim = config.get("optim", "adamw_8bit"),
|
||||
lr_scheduler_type = config.get("lr_scheduler_type", "linear"),
|
||||
)
|
||||
|
||||
# Check final state
|
||||
progress = trainer.get_training_progress()
|
||||
if progress.error:
|
||||
event_queue.put({
|
||||
"type": "error",
|
||||
"error": progress.error,
|
||||
"stack": "",
|
||||
"ts": time.time(),
|
||||
})
|
||||
event_queue.put(
|
||||
{
|
||||
"type": "error",
|
||||
"error": progress.error,
|
||||
"stack": "",
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
else:
|
||||
event_queue.put({
|
||||
"type": "complete",
|
||||
"output_dir": output_dir,
|
||||
"status_message": progress.status_message or "Training completed",
|
||||
"ts": time.time(),
|
||||
})
|
||||
event_queue.put(
|
||||
{
|
||||
"type": "complete",
|
||||
"output_dir": output_dir,
|
||||
"status_message": progress.status_message or "Training completed",
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
event_queue.put({
|
||||
"type": "error",
|
||||
"error": str(exc),
|
||||
"stack": traceback.format_exc(limit=20),
|
||||
"ts": time.time(),
|
||||
})
|
||||
event_queue.put(
|
||||
{
|
||||
"type": "error",
|
||||
"error": str(exc),
|
||||
"stack": traceback.format_exc(limit = 20),
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _send_status(event_queue: Any, message: str) -> None:
|
||||
"""Send a status update to the parent process."""
|
||||
event_queue.put({
|
||||
"type": "status",
|
||||
"message": message,
|
||||
"ts": time.time(),
|
||||
})
|
||||
event_queue.put(
|
||||
{
|
||||
"type": "status",
|
||||
"message": message,
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _run_embedding_training(event_queue: Any, stop_queue: Any, config: dict) -> None:
|
||||
|
|
@ -469,14 +549,17 @@ def _run_embedding_training(event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
from sentence_transformers.training_args import BatchSamplers
|
||||
from datasets import load_dataset, Dataset
|
||||
from transformers import TrainerCallback
|
||||
from utils.paths import datasets_root, resolve_output_dir
|
||||
except ImportError as e:
|
||||
event_queue.put({
|
||||
"type": "error",
|
||||
"error": f"Failed to import embedding libraries: {e}. "
|
||||
"Ensure 'sentence_transformers' and 'unsloth' are installed.",
|
||||
"stack": traceback.format_exc(limit=20),
|
||||
"ts": time.time(),
|
||||
})
|
||||
event_queue.put(
|
||||
{
|
||||
"type": "error",
|
||||
"error": f"Failed to import embedding libraries: {e}. "
|
||||
"Ensure 'sentence_transformers' and 'unsloth' are installed.",
|
||||
"stack": traceback.format_exc(limit = 20),
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
# ── Stop signal handling ──
|
||||
|
|
@ -487,18 +570,21 @@ def _run_embedding_training(event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
nonlocal _should_stop, _save_on_stop
|
||||
while True:
|
||||
try:
|
||||
msg = stop_queue.get(timeout=1.0)
|
||||
msg = stop_queue.get(timeout = 1.0)
|
||||
if msg and msg.get("type") == "stop":
|
||||
_save_on_stop = msg.get("save", True)
|
||||
_should_stop = True
|
||||
logger.info("Embedding training: stop signal received (save=%s)", _save_on_stop)
|
||||
logger.info(
|
||||
"Embedding training: stop signal received (save=%s)",
|
||||
_save_on_stop,
|
||||
)
|
||||
return
|
||||
except _queue.Empty:
|
||||
continue
|
||||
except (EOFError, OSError):
|
||||
return
|
||||
|
||||
stop_thread = threading.Thread(target=_poll_stop, daemon=True)
|
||||
stop_thread = threading.Thread(target = _poll_stop, daemon = True)
|
||||
stop_thread.start()
|
||||
|
||||
# ── 2. Load model ──
|
||||
|
|
@ -508,21 +594,23 @@ def _run_embedding_training(event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
hf_token = hf_token if hf_token and hf_token.strip() else None
|
||||
max_seq_length = config.get("max_seq_length", 512)
|
||||
training_type = config.get("training_type", "LoRA/QLoRA")
|
||||
use_lora = (training_type == "LoRA/QLoRA")
|
||||
use_lora = training_type == "LoRA/QLoRA"
|
||||
|
||||
model = FastSentenceTransformer.from_pretrained(
|
||||
model_name=model_name,
|
||||
max_seq_length=max_seq_length,
|
||||
full_finetuning=not use_lora,
|
||||
token=hf_token,
|
||||
model_name = model_name,
|
||||
max_seq_length = max_seq_length,
|
||||
full_finetuning = not use_lora,
|
||||
token = hf_token,
|
||||
)
|
||||
except Exception as e:
|
||||
event_queue.put({
|
||||
"type": "error",
|
||||
"error": f"Failed to load embedding model '{model_name}': {e}",
|
||||
"stack": traceback.format_exc(limit=20),
|
||||
"ts": time.time(),
|
||||
})
|
||||
event_queue.put(
|
||||
{
|
||||
"type": "error",
|
||||
"error": f"Failed to load embedding model '{model_name}': {e}",
|
||||
"stack": traceback.format_exc(limit = 20),
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
if _should_stop:
|
||||
|
|
@ -540,24 +628,29 @@ def _run_embedding_training(event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
|
||||
model = FastSentenceTransformer.get_peft_model(
|
||||
model,
|
||||
r=config.get("lora_r", 32),
|
||||
target_modules=config.get("target_modules") or ["q_proj", "k_proj", "v_proj", "o_proj"],
|
||||
lora_alpha=config.get("lora_alpha", 64),
|
||||
lora_dropout=config.get("lora_dropout", 0.0),
|
||||
bias="none",
|
||||
use_gradient_checkpointing=gradient_checkpointing,
|
||||
random_state=config.get("random_seed", 3407),
|
||||
use_rslora=config.get("use_rslora", False),
|
||||
loftq_config={"loftq_bits": 4, "loftq_iter": 1} if config.get("use_loftq") else None,
|
||||
task_type="FEATURE_EXTRACTION",
|
||||
r = config.get("lora_r", 32),
|
||||
target_modules = config.get("target_modules")
|
||||
or ["q_proj", "k_proj", "v_proj", "o_proj"],
|
||||
lora_alpha = config.get("lora_alpha", 64),
|
||||
lora_dropout = config.get("lora_dropout", 0.0),
|
||||
bias = "none",
|
||||
use_gradient_checkpointing = gradient_checkpointing,
|
||||
random_state = config.get("random_seed", 3407),
|
||||
use_rslora = config.get("use_rslora", False),
|
||||
loftq_config = {"loftq_bits": 4, "loftq_iter": 1}
|
||||
if config.get("use_loftq")
|
||||
else None,
|
||||
task_type = "FEATURE_EXTRACTION",
|
||||
)
|
||||
except Exception as e:
|
||||
event_queue.put({
|
||||
"type": "error",
|
||||
"error": f"Failed to configure LoRA for embedding model: {e}",
|
||||
"stack": traceback.format_exc(limit=20),
|
||||
"ts": time.time(),
|
||||
})
|
||||
event_queue.put(
|
||||
{
|
||||
"type": "error",
|
||||
"error": f"Failed to configure LoRA for embedding model: {e}",
|
||||
"stack": traceback.format_exc(limit = 20),
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
if _should_stop:
|
||||
|
|
@ -578,16 +671,21 @@ def _run_embedding_training(event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
dataset = load_dataset(
|
||||
hf_dataset.strip(),
|
||||
subset,
|
||||
split=train_split,
|
||||
token=hf_token,
|
||||
split = train_split,
|
||||
token = hf_token,
|
||||
)
|
||||
elif local_datasets:
|
||||
# Load from local file(s) — mirrors the non-embedding pipeline's
|
||||
# directory handling so recipe outputs (parquet-files/) work.
|
||||
all_files: list[str] = []
|
||||
for dataset_file in local_datasets:
|
||||
file_path = dataset_file if os.path.isabs(dataset_file) else os.path.join(
|
||||
str(datasets_root()), dataset_file,
|
||||
file_path = (
|
||||
dataset_file
|
||||
if os.path.isabs(dataset_file)
|
||||
else os.path.join(
|
||||
str(datasets_root()),
|
||||
dataset_file,
|
||||
)
|
||||
)
|
||||
if os.path.isdir(file_path):
|
||||
file_path_obj = Path(file_path)
|
||||
|
|
@ -606,7 +704,9 @@ def _run_embedding_training(event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
if candidates:
|
||||
all_files.extend(str(c) for c in candidates)
|
||||
continue
|
||||
raise ValueError(f"No supported data files in directory: {file_path_obj}")
|
||||
raise ValueError(
|
||||
f"No supported data files in directory: {file_path_obj}"
|
||||
)
|
||||
else:
|
||||
all_files.append(file_path)
|
||||
|
||||
|
|
@ -619,14 +719,19 @@ def _run_embedding_training(event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
elif first_ext == ".parquet":
|
||||
loader = "parquet"
|
||||
else:
|
||||
raise ValueError(f"Unsupported local dataset format: {all_files[0]}")
|
||||
dataset = load_dataset(loader, data_files=all_files, split="train")
|
||||
raise ValueError(
|
||||
f"Unsupported local dataset format: {all_files[0]}"
|
||||
)
|
||||
dataset = load_dataset(loader, data_files = all_files, split = "train")
|
||||
else:
|
||||
event_queue.put({
|
||||
"type": "error",
|
||||
"error": "No dataset specified for embedding training.",
|
||||
"stack": "", "ts": time.time(),
|
||||
})
|
||||
event_queue.put(
|
||||
{
|
||||
"type": "error",
|
||||
"error": "No dataset specified for embedding training.",
|
||||
"stack": "",
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
# Apply dataset slicing if specified
|
||||
|
|
@ -639,12 +744,14 @@ def _run_embedding_training(event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
|
||||
logger.info(f"Embedding dataset loaded: {len(dataset)} samples")
|
||||
except Exception as e:
|
||||
event_queue.put({
|
||||
"type": "error",
|
||||
"error": f"Failed to load dataset: {e}",
|
||||
"stack": traceback.format_exc(limit=20),
|
||||
"ts": time.time(),
|
||||
})
|
||||
event_queue.put(
|
||||
{
|
||||
"type": "error",
|
||||
"error": f"Failed to load dataset: {e}",
|
||||
"stack": traceback.format_exc(limit = 20),
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
if _should_stop:
|
||||
|
|
@ -659,16 +766,21 @@ def _run_embedding_training(event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
try:
|
||||
lr_value = float(config.get("learning_rate", "2e-4"))
|
||||
except ValueError:
|
||||
event_queue.put({
|
||||
"type": "error",
|
||||
"error": f"Invalid learning rate: {config.get('learning_rate')}",
|
||||
"stack": "", "ts": time.time(),
|
||||
})
|
||||
event_queue.put(
|
||||
{
|
||||
"type": "error",
|
||||
"error": f"Invalid learning rate: {config.get('learning_rate')}",
|
||||
"stack": "",
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
output_dir = config.get("output_dir")
|
||||
if not output_dir:
|
||||
output_dir = f"./outputs/{model_name.replace('/', '_')}_{int(time.time())}"
|
||||
output_dir = str(
|
||||
resolve_output_dir(f"{model_name.replace('/', '_')}_{int(time.time())}")
|
||||
)
|
||||
|
||||
num_epochs = config.get("num_epochs", 2)
|
||||
batch_size = config.get("batch_size", 256)
|
||||
|
|
@ -728,7 +840,7 @@ def _run_embedding_training(event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
class _EmbeddingProgressCallback(TrainerCallback):
|
||||
"""Sends training progress events to the parent process via event_queue."""
|
||||
|
||||
def on_log(self, args, state, control, logs=None, **kwargs):
|
||||
def on_log(self, args, state, control, logs = None, **kwargs):
|
||||
if not logs:
|
||||
return
|
||||
loss_value = logs.get("loss", logs.get("train_loss", 0.0))
|
||||
|
|
@ -741,21 +853,23 @@ def _run_embedding_training(event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
if remaining > 0:
|
||||
eta = (elapsed / current_step) * remaining
|
||||
|
||||
event_queue.put({
|
||||
"type": "progress",
|
||||
"step": current_step,
|
||||
"epoch": round(state.epoch, 2) if state.epoch else 0,
|
||||
"loss": loss_value,
|
||||
"learning_rate": logs.get("learning_rate", 0.0),
|
||||
"total_steps": total_steps,
|
||||
"elapsed_seconds": elapsed,
|
||||
"eta_seconds": eta,
|
||||
"grad_norm": logs.get("grad_norm"),
|
||||
"num_tokens": getattr(state, "num_input_tokens_seen", None),
|
||||
"eval_loss": logs.get("eval_loss"),
|
||||
"status_message": "",
|
||||
"ts": time.time(),
|
||||
})
|
||||
event_queue.put(
|
||||
{
|
||||
"type": "progress",
|
||||
"step": current_step,
|
||||
"epoch": round(state.epoch, 2) if state.epoch else 0,
|
||||
"loss": loss_value,
|
||||
"learning_rate": logs.get("learning_rate", 0.0),
|
||||
"total_steps": total_steps,
|
||||
"elapsed_seconds": elapsed,
|
||||
"eta_seconds": eta,
|
||||
"grad_norm": logs.get("grad_norm"),
|
||||
"num_tokens": getattr(state, "num_input_tokens_seen", None),
|
||||
"eval_loss": logs.get("eval_loss"),
|
||||
"status_message": "",
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
|
||||
def on_step_end(self, args, state, control, **kwargs):
|
||||
if _should_stop:
|
||||
|
|
@ -767,31 +881,35 @@ def _run_embedding_training(event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
_send_status(event_queue, "Starting embedding training...")
|
||||
try:
|
||||
trainer = SentenceTransformerTrainer(
|
||||
model=model,
|
||||
train_dataset=dataset,
|
||||
loss=loss,
|
||||
args=args,
|
||||
callbacks=[_EmbeddingProgressCallback()],
|
||||
model = model,
|
||||
train_dataset = dataset,
|
||||
loss = loss,
|
||||
args = args,
|
||||
callbacks = [_EmbeddingProgressCallback()],
|
||||
)
|
||||
|
||||
trainer.train()
|
||||
except Exception as e:
|
||||
event_queue.put({
|
||||
"type": "error",
|
||||
"error": f"Embedding training failed: {e}",
|
||||
"stack": traceback.format_exc(limit=20),
|
||||
"ts": time.time(),
|
||||
})
|
||||
event_queue.put(
|
||||
{
|
||||
"type": "error",
|
||||
"error": f"Embedding training failed: {e}",
|
||||
"stack": traceback.format_exc(limit = 20),
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
# ── 10. Save model ──
|
||||
if _should_stop and not _save_on_stop:
|
||||
event_queue.put({
|
||||
"type": "complete",
|
||||
"output_dir": None,
|
||||
"status_message": "Training cancelled",
|
||||
"ts": time.time(),
|
||||
})
|
||||
event_queue.put(
|
||||
{
|
||||
"type": "complete",
|
||||
"output_dir": None,
|
||||
"status_message": "Training cancelled",
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
_send_status(event_queue, "Saving model...")
|
||||
|
|
@ -801,18 +919,22 @@ def _run_embedding_training(event_queue: Any, stop_queue: Any, config: dict) ->
|
|||
logger.info("Embedding model saved to %s", output_dir)
|
||||
except Exception as e:
|
||||
logger.error("Failed to save embedding model: %s", e)
|
||||
event_queue.put({
|
||||
"type": "error",
|
||||
"error": f"Training completed but failed to save: {e}",
|
||||
"stack": traceback.format_exc(limit=20),
|
||||
"ts": time.time(),
|
||||
})
|
||||
event_queue.put(
|
||||
{
|
||||
"type": "error",
|
||||
"error": f"Training completed but failed to save: {e}",
|
||||
"stack": traceback.format_exc(limit = 20),
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
return
|
||||
|
||||
# ── 11. Done ──
|
||||
event_queue.put({
|
||||
"type": "complete",
|
||||
"output_dir": output_dir,
|
||||
"status_message": "Embedding training completed",
|
||||
"ts": time.time(),
|
||||
})
|
||||
event_queue.put(
|
||||
{
|
||||
"type": "complete",
|
||||
"output_dir": output_dir,
|
||||
"status_message": "Embedding training completed",
|
||||
"ts": time.time(),
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
from .handlers import get_logger
|
||||
|
||||
__all__ = ["get_logger"]
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Logging configuration for structured logging with structlog.
|
||||
|
||||
This module provides centralized logging configuration with environment-specific
|
||||
|
|
@ -19,6 +22,7 @@ from typing import Optional
|
|||
|
||||
import structlog
|
||||
|
||||
|
||||
class LogConfig:
|
||||
"""Structured logging configuration for the application.
|
||||
|
||||
|
|
@ -41,9 +45,9 @@ class LogConfig:
|
|||
log_level = getattr(logging, log_level_name, logging.INFO)
|
||||
|
||||
structlog.configure(
|
||||
processors=[
|
||||
processors = [
|
||||
# Reorder processors to control field order
|
||||
structlog.processors.TimeStamper(fmt="iso"), # timestamp first
|
||||
structlog.processors.TimeStamper(fmt = "iso"), # timestamp first
|
||||
structlog.processors.add_log_level, # level second
|
||||
structlog.contextvars.merge_contextvars,
|
||||
# Custom processor to flatten the extra field
|
||||
|
|
@ -59,14 +63,14 @@ class LogConfig:
|
|||
},
|
||||
},
|
||||
(
|
||||
structlog.processors.JSONRenderer(sort_keys=False) # Preserve order
|
||||
structlog.processors.JSONRenderer(sort_keys = False) # Preserve order
|
||||
if env == "production"
|
||||
else structlog.dev.ConsoleRenderer()
|
||||
),
|
||||
],
|
||||
wrapper_class=structlog.make_filtering_bound_logger(log_level),
|
||||
logger_factory=structlog.PrintLoggerFactory(file=sys.stdout),
|
||||
cache_logger_on_first_use=True,
|
||||
wrapper_class = structlog.make_filtering_bound_logger(log_level),
|
||||
logger_factory = structlog.PrintLoggerFactory(file = sys.stdout),
|
||||
cache_logger_on_first_use = True,
|
||||
)
|
||||
|
||||
return structlog.get_logger(service_name)
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Logging handlers and middleware for structured logging.
|
||||
|
||||
This module provides FastAPI middleware and structlog processors for:
|
||||
|
|
@ -36,21 +39,23 @@ class LoggingMiddleware(BaseHTTPMiddleware):
|
|||
"/api/train/status",
|
||||
"/api/train/metrics",
|
||||
"/api/train/hardware",
|
||||
"/api/system"
|
||||
"/api/system",
|
||||
}
|
||||
is_excluded = (
|
||||
request.url.path in EXCLUDED_PATHS
|
||||
or request.url.path.startswith("/assets/")
|
||||
or request.url.path.endswith((".png", ".jpg", ".jpeg", ".ico", ".woff", ".woff2", ".ttf"))
|
||||
or request.url.path.endswith(
|
||||
(".png", ".jpg", ".jpeg", ".ico", ".woff", ".woff2", ".ttf")
|
||||
)
|
||||
)
|
||||
|
||||
if not is_excluded:
|
||||
logger.info(
|
||||
"request_completed",
|
||||
method=request.method,
|
||||
path=request.url.path,
|
||||
status_code=response.status_code,
|
||||
process_time_ms=round(process_time, 2),
|
||||
method = request.method,
|
||||
path = request.url.path,
|
||||
status_code = response.status_code,
|
||||
process_time_ms = round(process_time, 2),
|
||||
)
|
||||
|
||||
return response
|
||||
|
|
@ -58,10 +63,10 @@ class LoggingMiddleware(BaseHTTPMiddleware):
|
|||
except Exception as e:
|
||||
logger.error(
|
||||
"request_failed",
|
||||
path=request.url.path,
|
||||
method=request.method,
|
||||
error=str(e),
|
||||
exc_info=True,
|
||||
path = request.url.path,
|
||||
method = request.method,
|
||||
error = str(e),
|
||||
exc_info = True,
|
||||
)
|
||||
raise
|
||||
|
||||
|
|
|
|||
|
|
@ -4,9 +4,11 @@
|
|||
"""
|
||||
Main FastAPI application for Unsloth UI Backend
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
# Suppress annoying C-level dependency warnings globally
|
||||
os.environ["PYTHONWARNINGS"] = "ignore"
|
||||
os.environ["PYTHONWARNINGS"] = "ignore"
|
||||
|
||||
import secrets
|
||||
import shutil
|
||||
|
|
@ -54,7 +56,7 @@ async def lifespan(app: FastAPI):
|
|||
# Version switching now uses .venv_t5/ (pre-installed by setup.sh).
|
||||
overlay_dir = Path(__file__).resolve().parent.parent.parent / ".venv_overlay"
|
||||
if overlay_dir.is_dir():
|
||||
shutil.rmtree(overlay_dir, ignore_errors=True)
|
||||
shutil.rmtree(overlay_dir, ignore_errors = True)
|
||||
|
||||
# Detect hardware first — sets DEVICE global used everywhere
|
||||
detect_hardware()
|
||||
|
|
@ -62,12 +64,14 @@ async def lifespan(app: FastAPI):
|
|||
# Disable flex attention on Blackwell+ GPUs (sm_120 and above)
|
||||
if get_device() == DeviceType.CUDA:
|
||||
import torch
|
||||
|
||||
props = torch.cuda.get_device_properties(0)
|
||||
sm_version = props.major * 10 + props.minor
|
||||
if sm_version >= 120:
|
||||
os.environ["UNSLOTH_ENABLE_FLEX_ATTENTION"] = "0"
|
||||
import structlog
|
||||
from loggers import get_logger
|
||||
|
||||
get_logger(__name__).info(
|
||||
f"GPU sm_{sm_version} detected — setting UNSLOTH_FLEX_ATTENTION=0"
|
||||
)
|
||||
|
|
@ -75,13 +79,16 @@ async def lifespan(app: FastAPI):
|
|||
# Pre-cache the helper GGUF model for LLM-assisted dataset detection.
|
||||
# Runs in a background thread so it doesn't block server startup.
|
||||
import threading
|
||||
|
||||
def _precache():
|
||||
try:
|
||||
from utils.datasets.llm_assist import precache_helper_gguf
|
||||
|
||||
precache_helper_gguf()
|
||||
except Exception:
|
||||
pass # non-critical
|
||||
threading.Thread(target=_precache, daemon=True).start()
|
||||
|
||||
threading.Thread(target = _precache, daemon = True).start()
|
||||
|
||||
if not storage.is_initialized():
|
||||
setup_token = secrets.token_urlsafe(32)
|
||||
|
|
@ -100,10 +107,10 @@ async def lifespan(app: FastAPI):
|
|||
|
||||
# Create FastAPI app
|
||||
app = FastAPI(
|
||||
title="Unsloth UI Backend",
|
||||
version="1.0.0",
|
||||
description="Backend API for Unsloth UI - Training and Model Management",
|
||||
lifespan=lifespan,
|
||||
title = "Unsloth UI Backend",
|
||||
version = "1.0.0",
|
||||
description = "Backend API for Unsloth UI - Training and Model Management",
|
||||
lifespan = lifespan,
|
||||
)
|
||||
|
||||
# Initialize structured logging
|
||||
|
|
@ -111,8 +118,8 @@ from loggers.config import LogConfig
|
|||
from loggers.handlers import LoggingMiddleware
|
||||
|
||||
logger = LogConfig.setup_logging(
|
||||
service_name="unsloth-studio-backend",
|
||||
env=os.getenv("ENVIRONMENT_TYPE", "production")
|
||||
service_name = "unsloth-studio-backend",
|
||||
env = os.getenv("ENVIRONMENT_TYPE", "production"),
|
||||
)
|
||||
|
||||
app.add_middleware(LoggingMiddleware)
|
||||
|
|
@ -120,38 +127,39 @@ app.add_middleware(LoggingMiddleware)
|
|||
# CORS middleware
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"], # In production, specify allowed origins
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_origins = ["*"], # In production, specify allowed origins
|
||||
allow_credentials = True,
|
||||
allow_methods = ["*"],
|
||||
allow_headers = ["*"],
|
||||
)
|
||||
|
||||
# ============ Register API Routes ============
|
||||
|
||||
# Register routers
|
||||
app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
|
||||
app.include_router(training_router, prefix="/api/train", tags=["training"])
|
||||
app.include_router(models_router, prefix="/api/models", tags=["models"])
|
||||
app.include_router(inference_router, prefix="/api/inference", tags=["inference"])
|
||||
app.include_router(auth_router, prefix = "/api/auth", tags = ["auth"])
|
||||
app.include_router(training_router, prefix = "/api/train", tags = ["training"])
|
||||
app.include_router(models_router, prefix = "/api/models", tags = ["models"])
|
||||
app.include_router(inference_router, prefix = "/api/inference", tags = ["inference"])
|
||||
|
||||
# OpenAI-compatible endpoints: mount the same inference router at /v1
|
||||
# so external tools (Open WebUI, SillyTavern, etc.) can use the
|
||||
# standard /v1/chat/completions path.
|
||||
app.include_router(inference_router, prefix="/v1", tags=["openai-compat"])
|
||||
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"])
|
||||
app.include_router(inference_router, prefix = "/v1", tags = ["openai-compat"])
|
||||
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 ============
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
async def health_check():
|
||||
"""Health check endpoint"""
|
||||
return {
|
||||
"status": "healthy",
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"service": "Unsloth UI Backend"
|
||||
"service": "Unsloth UI Backend",
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -167,11 +175,13 @@ async def get_system_info():
|
|||
gpu_info = {"available": mem_info.get("available", False), "devices": []}
|
||||
|
||||
if mem_info.get("available"):
|
||||
gpu_info["devices"].append({
|
||||
"index": mem_info.get("device", 0),
|
||||
"name": mem_info.get("device_name", "Unknown"),
|
||||
"memory_total_gb": round(mem_info.get("total_gb", 0), 2),
|
||||
})
|
||||
gpu_info["devices"].append(
|
||||
{
|
||||
"index": mem_info.get("device", 0),
|
||||
"name": mem_info.get("device_name", "Unknown"),
|
||||
"memory_total_gb": round(mem_info.get("total_gb", 0), 2),
|
||||
}
|
||||
)
|
||||
|
||||
# CPU & Memory
|
||||
memory = psutil.virtual_memory()
|
||||
|
|
@ -203,6 +213,7 @@ async def get_hardware_info():
|
|||
|
||||
# ============ Serve Frontend (Optional) ============
|
||||
|
||||
|
||||
def setup_frontend(app: FastAPI, build_path: Path):
|
||||
"""Mount frontend static files (optional)"""
|
||||
if not build_path.exists():
|
||||
|
|
@ -211,15 +222,15 @@ def setup_frontend(app: FastAPI, build_path: Path):
|
|||
# Mount assets
|
||||
assets_dir = build_path / "assets"
|
||||
if assets_dir.exists():
|
||||
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
|
||||
app.mount("/assets", StaticFiles(directory = assets_dir), name = "assets")
|
||||
|
||||
@app.get("/")
|
||||
async def serve_root():
|
||||
content = (build_path / "index.html").read_bytes()
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="text/html",
|
||||
headers={"Cache-Control": "no-cache, no-store, must-revalidate"},
|
||||
content = content,
|
||||
media_type = "text/html",
|
||||
headers = {"Cache-Control": "no-cache, no-store, must-revalidate"},
|
||||
)
|
||||
|
||||
@app.get("/{full_path:path}")
|
||||
|
|
@ -230,8 +241,8 @@ def setup_frontend(app: FastAPI, build_path: Path):
|
|||
file_path = (build_path / full_path).resolve()
|
||||
|
||||
# Block path traversal — ensure resolved path stays inside build_path
|
||||
if not str(file_path).startswith(str(build_path.resolve())):
|
||||
return Response(status_code=403)
|
||||
if not file_path.is_relative_to(build_path.resolve()):
|
||||
return Response(status_code = 403)
|
||||
|
||||
if file_path.is_file():
|
||||
return FileResponse(file_path)
|
||||
|
|
@ -239,10 +250,9 @@ def setup_frontend(app: FastAPI, build_path: Path):
|
|||
# Serve index.html as bytes — avoids Content-Length mismatch
|
||||
content = (build_path / "index.html").read_bytes()
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="text/html",
|
||||
headers={"Cache-Control": "no-cache, no-store, must-revalidate"},
|
||||
content = content,
|
||||
media_type = "text/html",
|
||||
headers = {"Cache-Control": "no-cache, no-store, must-revalidate"},
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"""
|
||||
Pydantic models for API request/response schemas
|
||||
"""
|
||||
|
||||
from .training import (
|
||||
TrainingStartRequest,
|
||||
TrainingJobResponse,
|
||||
|
|
|
|||
|
|
@ -4,28 +4,38 @@
|
|||
"""
|
||||
Pydantic schemas for Authentication API
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AuthSetupRequest(BaseModel):
|
||||
"""First-time setup: create the initial admin user + password."""
|
||||
setup_token: str = Field(..., description="One-time setup token printed to the server console")
|
||||
username: str = Field(..., description="Admin username")
|
||||
password: str = Field(..., min_length=8, description="Admin password (minimum 8 characters)")
|
||||
|
||||
setup_token: str = Field(
|
||||
..., description = "One-time setup token printed to the server console"
|
||||
)
|
||||
username: str = Field(..., description = "Admin username")
|
||||
password: str = Field(
|
||||
..., min_length = 8, description = "Admin password (minimum 8 characters)"
|
||||
)
|
||||
|
||||
|
||||
class AuthLoginRequest(BaseModel):
|
||||
"""Login payload: username/password to obtain a JWT."""
|
||||
username: str = Field(..., description="Username")
|
||||
password: str = Field(..., description="Password")
|
||||
|
||||
username: str = Field(..., description = "Username")
|
||||
password: str = Field(..., description = "Password")
|
||||
|
||||
|
||||
class RefreshTokenRequest(BaseModel):
|
||||
"""Refresh token payload to obtain new access + refresh tokens."""
|
||||
refresh_token: str = Field(..., description="Refresh token from a previous login or refresh")
|
||||
|
||||
refresh_token: str = Field(
|
||||
..., description = "Refresh token from a previous login or refresh"
|
||||
)
|
||||
|
||||
|
||||
class AuthStatusResponse(BaseModel):
|
||||
"""Indicate whether auth has been initialized."""
|
||||
initialized: bool = Field(..., description="True if auth setup has been completed")
|
||||
|
||||
initialized: bool = Field(..., description = "True if auth setup has been completed")
|
||||
|
|
|
|||
|
|
@ -13,13 +13,13 @@ from pydantic import BaseModel, Field
|
|||
|
||||
|
||||
class RecipePayload(BaseModel):
|
||||
recipe: dict[str, Any] = Field(default_factory=dict)
|
||||
recipe: dict[str, Any] = Field(default_factory = dict)
|
||||
run: dict[str, Any] | None = None
|
||||
ui: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class PreviewResponse(BaseModel):
|
||||
dataset: list[dict[str, Any]] = Field(default_factory=list)
|
||||
dataset: list[dict[str, Any]] = Field(default_factory = list)
|
||||
processor_artifacts: dict[str, Any] | None = None
|
||||
analysis: dict[str, Any] | None = None
|
||||
|
||||
|
|
@ -32,7 +32,7 @@ class ValidateError(BaseModel):
|
|||
|
||||
class ValidateResponse(BaseModel):
|
||||
valid: bool
|
||||
errors: list[ValidateError] = Field(default_factory=list)
|
||||
errors: list[ValidateError] = Field(default_factory = list)
|
||||
raw_detail: str | None = None
|
||||
|
||||
|
||||
|
|
@ -41,42 +41,42 @@ class JobCreateResponse(BaseModel):
|
|||
|
||||
|
||||
class SeedInspectRequest(BaseModel):
|
||||
dataset_name: str = Field(min_length=1)
|
||||
dataset_name: str = Field(min_length = 1)
|
||||
hf_token: str | None = None
|
||||
subset: str | None = None
|
||||
split: str | None = "train"
|
||||
preview_size: int = Field(default=10, ge=1, le=50)
|
||||
preview_size: int = Field(default = 10, ge = 1, le = 50)
|
||||
|
||||
|
||||
class SeedInspectUploadRequest(BaseModel):
|
||||
filename: str = Field(min_length=1)
|
||||
content_base64: str = Field(min_length=1)
|
||||
preview_size: int = Field(default=10, ge=1, le=50)
|
||||
filename: str = Field(min_length = 1)
|
||||
content_base64: str = Field(min_length = 1)
|
||||
preview_size: int = Field(default = 10, ge = 1, le = 50)
|
||||
seed_source_type: str | None = None
|
||||
unstructured_chunk_size: int | None = Field(default=None, ge=1, le=20000)
|
||||
unstructured_chunk_overlap: int | None = Field(default=None, ge=0, le=20000)
|
||||
unstructured_chunk_size: int | None = Field(default = None, ge = 1, le = 20000)
|
||||
unstructured_chunk_overlap: int | None = Field(default = None, ge = 0, le = 20000)
|
||||
|
||||
|
||||
class SeedInspectResponse(BaseModel):
|
||||
dataset_name: str
|
||||
resolved_path: str
|
||||
columns: list[str] = Field(default_factory=list)
|
||||
preview_rows: list[dict[str, Any]] = Field(default_factory=list)
|
||||
columns: list[str] = Field(default_factory = list)
|
||||
preview_rows: list[dict[str, Any]] = Field(default_factory = list)
|
||||
split: str | None = None
|
||||
subset: str | None = None
|
||||
|
||||
|
||||
class McpToolsListRequest(BaseModel):
|
||||
mcp_providers: list[dict[str, Any]] = Field(default_factory=list)
|
||||
timeout_sec: float | None = Field(default=None, gt=0)
|
||||
mcp_providers: list[dict[str, Any]] = Field(default_factory = list)
|
||||
timeout_sec: float | None = Field(default = None, gt = 0)
|
||||
|
||||
|
||||
class McpToolsProviderResult(BaseModel):
|
||||
name: str
|
||||
tools: list[str] = Field(default_factory=list)
|
||||
tools: list[str] = Field(default_factory = list)
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class McpToolsListResponse(BaseModel):
|
||||
providers: list[McpToolsProviderResult] = Field(default_factory=list)
|
||||
duplicate_tools: dict[str, list[str]] = Field(default_factory=dict)
|
||||
providers: list[McpToolsProviderResult] = Field(default_factory = list)
|
||||
duplicate_tools: dict[str, list[str]] = Field(default_factory = dict)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"""
|
||||
Dataset-related Pydantic models for API requests and responses.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
|
@ -11,13 +12,14 @@ from pydantic import BaseModel, Field, model_validator
|
|||
|
||||
class CheckFormatRequest(BaseModel):
|
||||
"""Request for dataset format check"""
|
||||
|
||||
dataset_name: str # HuggingFace dataset name or local path
|
||||
is_vlm: bool = False
|
||||
hf_token: Optional[str] = None
|
||||
subset: Optional[str] = None
|
||||
train_split: Optional[str] = "train"
|
||||
|
||||
@model_validator(mode="before")
|
||||
@model_validator(mode = "before")
|
||||
@classmethod
|
||||
def _compat_split(cls, values: Any) -> Any:
|
||||
"""Accept legacy 'split' field as alias for 'train_split'."""
|
||||
|
|
@ -28,6 +30,7 @@ class CheckFormatRequest(BaseModel):
|
|||
|
||||
class CheckFormatResponse(BaseModel):
|
||||
"""Response for dataset format check"""
|
||||
|
||||
requires_manual_mapping: bool
|
||||
detected_format: str
|
||||
columns: List[str]
|
||||
|
|
@ -46,6 +49,7 @@ class CheckFormatResponse(BaseModel):
|
|||
|
||||
class AiAssistMappingRequest(BaseModel):
|
||||
"""Request for LLM-assisted column classification (user-triggered)."""
|
||||
|
||||
columns: List[str]
|
||||
samples: List[Dict[str, Any]] # Preview rows already loaded in the dialog
|
||||
dataset_name: Optional[str] = None # For LLM context
|
||||
|
|
@ -56,6 +60,7 @@ class AiAssistMappingRequest(BaseModel):
|
|||
|
||||
class AiAssistMappingResponse(BaseModel):
|
||||
"""Response from LLM-assisted column classification and conversion advice."""
|
||||
|
||||
success: bool
|
||||
suggested_mapping: Optional[Dict[str, str]] = None
|
||||
warning: Optional[str] = None
|
||||
|
|
@ -69,8 +74,9 @@ class AiAssistMappingResponse(BaseModel):
|
|||
|
||||
class UploadDatasetResponse(BaseModel):
|
||||
"""Response with stored dataset path for training."""
|
||||
filename: str = Field(..., description="Original filename")
|
||||
stored_path: str = Field(..., description="Absolute path stored on backend")
|
||||
|
||||
filename: str = Field(..., description = "Original filename")
|
||||
stored_path: str = Field(..., description = "Absolute path stored on backend")
|
||||
|
||||
|
||||
class LocalDatasetItem(BaseModel):
|
||||
|
|
@ -90,4 +96,4 @@ class LocalDatasetItem(BaseModel):
|
|||
|
||||
|
||||
class LocalDatasetsResponse(BaseModel):
|
||||
datasets: List[LocalDatasetItem] = Field(default_factory=list)
|
||||
datasets: List[LocalDatasetItem] = Field(default_factory = list)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"""
|
||||
Pydantic schemas for Export API.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional, Literal, Dict, Any
|
||||
|
||||
|
|
@ -11,20 +12,20 @@ from typing import List, Optional, Literal, Dict, Any
|
|||
class LoadCheckpointRequest(BaseModel):
|
||||
"""Request for loading a checkpoint into the export backend."""
|
||||
|
||||
checkpoint_path: str = Field(..., description="Path to the checkpoint directory")
|
||||
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",
|
||||
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",
|
||||
description = "Whether to load the model in 4-bit quantization",
|
||||
)
|
||||
trust_remote_code: bool = Field(
|
||||
False,
|
||||
description="Allow loading models with custom code. Only enable for checkpoints/base models you trust.",
|
||||
description = "Allow loading models with custom code. Only enable for checkpoints/base models you trust.",
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -33,26 +34,26 @@ class ExportStatusResponse(BaseModel):
|
|||
|
||||
current_checkpoint: Optional[str] = Field(
|
||||
None,
|
||||
description="Path to the currently loaded checkpoint, if any",
|
||||
description = "Path to the currently loaded checkpoint, if any",
|
||||
)
|
||||
is_vision: bool = Field(
|
||||
False,
|
||||
description="True if the loaded checkpoint is a vision model",
|
||||
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",
|
||||
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")
|
||||
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",
|
||||
default = None,
|
||||
description = "Optional extra details about the operation",
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -61,27 +62,27 @@ class ExportCommonOptions(BaseModel):
|
|||
|
||||
save_directory: str = Field(
|
||||
...,
|
||||
description="Local directory where the exported artifacts will be written",
|
||||
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",
|
||||
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)",
|
||||
description = "Hugging Face Hub repository ID (username/model-name)",
|
||||
)
|
||||
hf_token: Optional[str] = Field(
|
||||
None,
|
||||
description="Hugging Face access token used for Hub operations",
|
||||
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)",
|
||||
description = "If True, create a private repository on the Hub (where applicable)",
|
||||
)
|
||||
base_model_id: Optional[str] = Field(
|
||||
None,
|
||||
description="HuggingFace model ID of the base model (for model card metadata)",
|
||||
description = "HuggingFace model ID of the base model (for model card metadata)",
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -90,7 +91,7 @@ class ExportMergedModelRequest(ExportCommonOptions):
|
|||
|
||||
format_type: Literal["16-bit (FP16)", "4-bit (FP4)"] = Field(
|
||||
"16-bit (FP16)",
|
||||
description="Export precision / format for the merged model",
|
||||
description = "Export precision / format for the merged model",
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -98,7 +99,6 @@ class ExportBaseModelRequest(ExportCommonOptions):
|
|||
"""Request for exporting a non-PEFT (base) model."""
|
||||
|
||||
# Uses fields from ExportCommonOptions only
|
||||
pass
|
||||
|
||||
|
||||
class ExportGGUFRequest(BaseModel):
|
||||
|
|
@ -106,23 +106,23 @@ class ExportGGUFRequest(BaseModel):
|
|||
|
||||
save_directory: str = Field(
|
||||
...,
|
||||
description="Directory where GGUF files will be saved",
|
||||
description = "Directory where GGUF files will be saved",
|
||||
)
|
||||
quantization_method: str = Field(
|
||||
"Q4_K_M",
|
||||
description='GGUF quantization method (e.g. "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",
|
||||
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",
|
||||
description = "Hugging Face Hub repository ID for GGUF upload",
|
||||
)
|
||||
hf_token: Optional[str] = Field(
|
||||
None,
|
||||
description="Hugging Face token for GGUF upload",
|
||||
description = "Hugging Face token for GGUF upload",
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -130,6 +130,3 @@ class ExportLoRAAdapterRequest(ExportCommonOptions):
|
|||
"""Request for exporting only the LoRA adapter (not merged)."""
|
||||
|
||||
# Uses fields from ExportCommonOptions only
|
||||
pass
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"""
|
||||
Pydantic schemas for Inference API
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
|
@ -15,21 +16,29 @@ from pydantic import BaseModel, Discriminator, Field, Tag
|
|||
|
||||
class LoadRequest(BaseModel):
|
||||
"""Request to load a model for inference"""
|
||||
model_path: str = Field(..., description="Model identifier or local path")
|
||||
hf_token: Optional[str] = Field(None, description="HuggingFace token for gated models")
|
||||
max_seq_length: int = Field(4096, ge=128, le=32768, description="Maximum sequence length")
|
||||
load_in_4bit: bool = Field(True, description="Load model in 4-bit quantization")
|
||||
is_lora: bool = Field(False, description="Whether this is a LoRA adapter")
|
||||
gguf_variant: Optional[str] = Field(None, description="GGUF quantization variant (e.g. 'Q4_K_M')")
|
||||
|
||||
model_path: str = Field(..., description = "Model identifier or local path")
|
||||
hf_token: Optional[str] = Field(
|
||||
None, description = "HuggingFace token for gated models"
|
||||
)
|
||||
max_seq_length: int = Field(
|
||||
4096, ge = 128, le = 32768, description = "Maximum sequence length"
|
||||
)
|
||||
load_in_4bit: bool = Field(True, description = "Load model in 4-bit quantization")
|
||||
is_lora: bool = Field(False, description = "Whether this is a LoRA adapter")
|
||||
gguf_variant: Optional[str] = Field(
|
||||
None, description = "GGUF quantization variant (e.g. 'Q4_K_M')"
|
||||
)
|
||||
trust_remote_code: bool = Field(
|
||||
False,
|
||||
description="Allow loading models with custom code (e.g. NVIDIA Nemotron). Only enable for repos you trust.",
|
||||
description = "Allow loading models with custom code (e.g. NVIDIA Nemotron). Only enable for repos you trust.",
|
||||
)
|
||||
|
||||
|
||||
class UnloadRequest(BaseModel):
|
||||
"""Request to unload a model"""
|
||||
model_path: str = Field(..., description="Model identifier to unload")
|
||||
|
||||
model_path: str = Field(..., description = "Model identifier to unload")
|
||||
|
||||
|
||||
class ValidateModelRequest(BaseModel):
|
||||
|
|
@ -39,9 +48,14 @@ class ValidateModelRequest(BaseModel):
|
|||
|
||||
This does NOT actually load weights into GPU memory.
|
||||
"""
|
||||
model_path: str = Field(..., description="Model identifier or local path")
|
||||
hf_token: Optional[str] = Field(None, description="HuggingFace token for gated models")
|
||||
gguf_variant: Optional[str] = Field(None, description="GGUF quantization variant (e.g. 'Q4_K_M')")
|
||||
|
||||
model_path: str = Field(..., description = "Model identifier or local path")
|
||||
hf_token: Optional[str] = Field(
|
||||
None, description = "HuggingFace token for gated models"
|
||||
)
|
||||
gguf_variant: Optional[str] = Field(
|
||||
None, description = "GGUF quantization variant (e.g. 'Q4_K_M')"
|
||||
)
|
||||
|
||||
|
||||
class ValidateModelResponse(BaseModel):
|
||||
|
|
@ -51,58 +65,99 @@ class ValidateModelResponse(BaseModel):
|
|||
valid == True means ModelConfig.from_identifier() succeeded and basic
|
||||
introspection (GGUF / LoRA / vision flags) is available.
|
||||
"""
|
||||
valid: bool = Field(..., description="Whether the model identifier looks valid")
|
||||
message: str = Field(..., description="Human-readable validation message")
|
||||
identifier: Optional[str] = Field(None, description="Resolved model identifier")
|
||||
display_name: Optional[str] = Field(None, description="Display name derived from identifier")
|
||||
is_gguf: bool = Field(False, description="Whether this is a GGUF model (llama.cpp)")
|
||||
is_lora: bool = Field(False, description="Whether this is a LoRA adapter")
|
||||
is_vision: bool = Field(False, description="Whether this is a vision-capable model")
|
||||
|
||||
valid: bool = Field(..., description = "Whether the model identifier looks valid")
|
||||
message: str = Field(..., description = "Human-readable validation message")
|
||||
identifier: Optional[str] = Field(None, description = "Resolved model identifier")
|
||||
display_name: Optional[str] = Field(
|
||||
None, description = "Display name derived from identifier"
|
||||
)
|
||||
is_gguf: bool = Field(False, description = "Whether this is a GGUF model (llama.cpp)")
|
||||
is_lora: bool = Field(False, description = "Whether this is a LoRA adapter")
|
||||
is_vision: bool = Field(False, description = "Whether this is a vision-capable model")
|
||||
|
||||
|
||||
class GenerateRequest(BaseModel):
|
||||
"""Request for text generation (legacy /generate/stream endpoint)"""
|
||||
messages: List[dict] = Field(..., description="Chat messages in OpenAI format")
|
||||
system_prompt: str = Field("You are a helpful AI assistant.", description="System prompt")
|
||||
temperature: float = Field(0.7, ge=0.0, le=2.0, description="Sampling temperature")
|
||||
top_p: float = Field(0.9, ge=0.0, le=1.0, description="Top-p sampling")
|
||||
top_k: int = Field(40, ge=-1, le=100, description="Top-k sampling")
|
||||
max_new_tokens: int = Field(2048, ge=1, le=4096, description="Maximum tokens to generate")
|
||||
repetition_penalty: float = Field(1.1, ge=1.0, le=2.0, description="Repetition penalty")
|
||||
image_base64: Optional[str] = Field(None, description="Base64 encoded image for vision models")
|
||||
|
||||
messages: List[dict] = Field(..., description = "Chat messages in OpenAI format")
|
||||
system_prompt: str = Field(
|
||||
"You are a helpful AI assistant.", description = "System prompt"
|
||||
)
|
||||
temperature: float = Field(0.7, ge = 0.0, le = 2.0, description = "Sampling temperature")
|
||||
top_p: float = Field(0.9, ge = 0.0, le = 1.0, description = "Top-p sampling")
|
||||
top_k: int = Field(40, ge = -1, le = 100, description = "Top-k sampling")
|
||||
max_new_tokens: int = Field(
|
||||
2048, ge = 1, le = 4096, description = "Maximum tokens to generate"
|
||||
)
|
||||
repetition_penalty: float = Field(
|
||||
1.1, ge = 1.0, le = 2.0, description = "Repetition penalty"
|
||||
)
|
||||
image_base64: Optional[str] = Field(
|
||||
None, description = "Base64 encoded image for vision models"
|
||||
)
|
||||
|
||||
|
||||
class LoadResponse(BaseModel):
|
||||
"""Response after loading a model"""
|
||||
status: str = Field(..., description="Load status")
|
||||
model: str = Field(..., description="Model identifier")
|
||||
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")
|
||||
is_gguf: bool = Field(False, description="Whether model is a GGUF model (llama.cpp)")
|
||||
is_audio: bool = Field(False, description="Whether model is a TTS audio model")
|
||||
audio_type: Optional[str] = Field(None, description="Audio codec type: snac, csm, bicodec, dac")
|
||||
has_audio_input: bool = Field(False, description="Whether model accepts audio input (ASR)")
|
||||
inference: dict = Field(..., description="Inference parameters (temperature, top_p, top_k, min_p)")
|
||||
|
||||
status: str = Field(..., description = "Load status")
|
||||
model: str = Field(..., description = "Model identifier")
|
||||
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")
|
||||
is_gguf: bool = Field(
|
||||
False, description = "Whether model is a GGUF model (llama.cpp)"
|
||||
)
|
||||
is_audio: bool = Field(False, description = "Whether model is a TTS audio model")
|
||||
audio_type: Optional[str] = Field(
|
||||
None, description = "Audio codec type: snac, csm, bicodec, dac"
|
||||
)
|
||||
has_audio_input: bool = Field(
|
||||
False, description = "Whether model accepts audio input (ASR)"
|
||||
)
|
||||
inference: dict = Field(
|
||||
..., description = "Inference parameters (temperature, top_p, top_k, min_p)"
|
||||
)
|
||||
|
||||
|
||||
class UnloadResponse(BaseModel):
|
||||
"""Response after unloading a model"""
|
||||
status: str = Field(..., description="Unload status")
|
||||
model: str = Field(..., description="Model identifier that was unloaded")
|
||||
|
||||
status: str = Field(..., description = "Unload status")
|
||||
model: str = Field(..., description = "Model identifier that was unloaded")
|
||||
|
||||
|
||||
class InferenceStatusResponse(BaseModel):
|
||||
"""Current inference backend status"""
|
||||
active_model: Optional[str] = Field(None, description="Currently active model identifier")
|
||||
is_vision: bool = Field(False, description="Whether the active model is a vision model")
|
||||
is_gguf: bool = Field(False, description="Whether the active model is a GGUF model (llama.cpp)")
|
||||
gguf_variant: Optional[str] = Field(None, description="GGUF quantization variant (e.g. Q4_K_M)")
|
||||
is_audio: bool = Field(False, description="Whether the active model is a TTS audio model")
|
||||
audio_type: Optional[str] = Field(None, description="Audio codec type: snac, csm, bicodec, dac")
|
||||
has_audio_input: bool = Field(False, description="Whether model accepts audio input (ASR)")
|
||||
loading: List[str] = Field(default_factory=list, description="Models currently being loaded")
|
||||
loaded: List[str] = Field(default_factory=list, description="Models currently loaded")
|
||||
|
||||
active_model: Optional[str] = Field(
|
||||
None, description = "Currently active model identifier"
|
||||
)
|
||||
is_vision: bool = Field(
|
||||
False, description = "Whether the active model is a vision model"
|
||||
)
|
||||
is_gguf: bool = Field(
|
||||
False, description = "Whether the active model is a GGUF model (llama.cpp)"
|
||||
)
|
||||
gguf_variant: Optional[str] = Field(
|
||||
None, description = "GGUF quantization variant (e.g. Q4_K_M)"
|
||||
)
|
||||
is_audio: bool = Field(
|
||||
False, description = "Whether the active model is a TTS audio model"
|
||||
)
|
||||
audio_type: Optional[str] = Field(
|
||||
None, description = "Audio codec type: snac, csm, bicodec, dac"
|
||||
)
|
||||
has_audio_input: bool = Field(
|
||||
False, description = "Whether model accepts audio input (ASR)"
|
||||
)
|
||||
loading: List[str] = Field(
|
||||
default_factory = list, description = "Models currently being loaded"
|
||||
)
|
||||
loaded: List[str] = Field(
|
||||
default_factory = list, description = "Models currently loaded"
|
||||
)
|
||||
|
||||
|
||||
# =====================================================================
|
||||
|
|
@ -112,20 +167,24 @@ class InferenceStatusResponse(BaseModel):
|
|||
|
||||
# ── Multimodal content parts (OpenAI vision format) ──────────────
|
||||
|
||||
|
||||
class TextContentPart(BaseModel):
|
||||
"""Text content part in a multimodal message."""
|
||||
|
||||
type: Literal["text"]
|
||||
text: str
|
||||
|
||||
|
||||
class ImageUrl(BaseModel):
|
||||
"""Image URL object — supports data URIs and remote URLs."""
|
||||
url: str = Field(..., description="data:image/png;base64,... or https://...")
|
||||
|
||||
url: str = Field(..., description = "data:image/png;base64,... or https://...")
|
||||
detail: Optional[Literal["auto", "low", "high"]] = "auto"
|
||||
|
||||
|
||||
class ImageContentPart(BaseModel):
|
||||
"""Image content part in a multimodal message."""
|
||||
|
||||
type: Literal["image_url"]
|
||||
image_url: ImageUrl
|
||||
|
||||
|
|
@ -148,6 +207,7 @@ ContentPart = Annotated[
|
|||
|
||||
# ── Messages ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ChatMessage(BaseModel):
|
||||
"""
|
||||
A single message in the conversation.
|
||||
|
|
@ -155,8 +215,13 @@ class ChatMessage(BaseModel):
|
|||
``content`` may be a plain string (text-only) or a list of
|
||||
content parts for multimodal messages (OpenAI vision format).
|
||||
"""
|
||||
role: Literal["system", "user", "assistant"] = Field(..., description="Message role")
|
||||
content: Union[str, list[ContentPart]] = Field(..., description="Message content (string or multimodal parts)")
|
||||
|
||||
role: Literal["system", "user", "assistant"] = Field(
|
||||
..., description = "Message role"
|
||||
)
|
||||
content: Union[str, list[ContentPart]] = Field(
|
||||
..., description = "Message content (string or multimodal parts)"
|
||||
)
|
||||
|
||||
|
||||
class ChatCompletionRequest(BaseModel):
|
||||
|
|
@ -165,22 +230,36 @@ class ChatCompletionRequest(BaseModel):
|
|||
|
||||
Extensions (non-OpenAI fields) are marked with 'x-unsloth'.
|
||||
"""
|
||||
model: str = Field("default", description="Model identifier (informational; the active model is used)")
|
||||
messages: list[ChatMessage] = Field(..., description="Conversation messages")
|
||||
stream: bool = Field(True, description="Whether to stream the response via SSE")
|
||||
temperature: float = Field(0.7, ge=0.0, le=2.0)
|
||||
top_p: float = Field(0.9, ge=0.0, le=1.0)
|
||||
max_tokens: Optional[int] = Field(2048, ge=1, le=4096, description="Maximum tokens to generate")
|
||||
|
||||
model: str = Field(
|
||||
"default",
|
||||
description = "Model identifier (informational; the active model is used)",
|
||||
)
|
||||
messages: list[ChatMessage] = Field(..., description = "Conversation messages")
|
||||
stream: bool = Field(True, description = "Whether to stream the response via SSE")
|
||||
temperature: float = Field(0.7, ge = 0.0, le = 2.0)
|
||||
top_p: float = Field(0.9, ge = 0.0, le = 1.0)
|
||||
max_tokens: Optional[int] = Field(
|
||||
2048, ge = 1, le = 4096, description = "Maximum tokens to generate"
|
||||
)
|
||||
|
||||
# ── Unsloth extensions (ignored by standard OpenAI clients) ──
|
||||
top_k: int = Field(40, ge=-1, le=100, description="[x-unsloth] Top-k sampling")
|
||||
min_p: float = Field(0.0, ge=0.0, le=1.0, description="[x-unsloth] Min-p sampling threshold")
|
||||
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")
|
||||
audio_base64: Optional[str] = Field(None, description="[x-unsloth] Base64-encoded WAV for audio-input models (ASR)")
|
||||
top_k: int = Field(40, ge = -1, le = 100, description = "[x-unsloth] Top-k sampling")
|
||||
min_p: float = Field(
|
||||
0.0, ge = 0.0, le = 1.0, description = "[x-unsloth] Min-p sampling threshold"
|
||||
)
|
||||
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"
|
||||
)
|
||||
audio_base64: Optional[str] = Field(
|
||||
None, description = "[x-unsloth] Base64-encoded WAV for audio-input models (ASR)"
|
||||
)
|
||||
use_adapter: Optional[Union[bool, str]] = Field(
|
||||
None,
|
||||
description=(
|
||||
description = (
|
||||
"[x-unsloth] Adapter control for compare mode. "
|
||||
"null = no change (default), "
|
||||
"false = disable adapters (base model), "
|
||||
|
|
@ -195,12 +274,14 @@ class ChatCompletionRequest(BaseModel):
|
|||
|
||||
class ChoiceDelta(BaseModel):
|
||||
"""Delta content for a streaming chunk."""
|
||||
|
||||
role: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
|
||||
|
||||
class ChunkChoice(BaseModel):
|
||||
"""A single choice in a streaming chunk."""
|
||||
|
||||
index: int = 0
|
||||
delta: ChoiceDelta
|
||||
finish_reason: Optional[Literal["stop", "length"]] = None
|
||||
|
|
@ -208,9 +289,10 @@ class ChunkChoice(BaseModel):
|
|||
|
||||
class ChatCompletionChunk(BaseModel):
|
||||
"""A single SSE chunk in OpenAI streaming format."""
|
||||
id: str = Field(default_factory=lambda: f"chatcmpl-{uuid.uuid4().hex[:12]}")
|
||||
|
||||
id: str = Field(default_factory = lambda: f"chatcmpl-{uuid.uuid4().hex[:12]}")
|
||||
object: Literal["chat.completion.chunk"] = "chat.completion.chunk"
|
||||
created: int = Field(default_factory=lambda: int(time.time()))
|
||||
created: int = Field(default_factory = lambda: int(time.time()))
|
||||
model: str = "default"
|
||||
choices: list[ChunkChoice]
|
||||
|
||||
|
|
@ -220,12 +302,14 @@ class ChatCompletionChunk(BaseModel):
|
|||
|
||||
class CompletionMessage(BaseModel):
|
||||
"""The assistant's complete response message."""
|
||||
|
||||
role: Literal["assistant"] = "assistant"
|
||||
content: str
|
||||
|
||||
|
||||
class CompletionChoice(BaseModel):
|
||||
"""A single choice in a non-streaming response."""
|
||||
|
||||
index: int = 0
|
||||
message: CompletionMessage
|
||||
finish_reason: Literal["stop", "length"] = "stop"
|
||||
|
|
@ -233,6 +317,7 @@ class CompletionChoice(BaseModel):
|
|||
|
||||
class CompletionUsage(BaseModel):
|
||||
"""Token usage statistics (approximate)."""
|
||||
|
||||
prompt_tokens: int = 0
|
||||
completion_tokens: int = 0
|
||||
total_tokens: int = 0
|
||||
|
|
@ -240,9 +325,10 @@ class CompletionUsage(BaseModel):
|
|||
|
||||
class ChatCompletion(BaseModel):
|
||||
"""Non-streaming chat completion response."""
|
||||
id: str = Field(default_factory=lambda: f"chatcmpl-{uuid.uuid4().hex[:12]}")
|
||||
|
||||
id: str = Field(default_factory = lambda: f"chatcmpl-{uuid.uuid4().hex[:12]}")
|
||||
object: Literal["chat.completion"] = "chat.completion"
|
||||
created: int = Field(default_factory=lambda: int(time.time()))
|
||||
created: int = Field(default_factory = lambda: int(time.time()))
|
||||
model: str = "default"
|
||||
choices: list[CompletionChoice]
|
||||
usage: CompletionUsage = Field(default_factory=CompletionUsage)
|
||||
usage: CompletionUsage = Field(default_factory = CompletionUsage)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"""
|
||||
Pydantic schemas for Model Management API
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List, Dict, Any, Literal
|
||||
|
||||
|
|
@ -13,123 +14,169 @@ ModelType = Literal["text", "vision", "audio", "embeddings"]
|
|||
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")
|
||||
loss: Optional[float] = Field(None, description="Training loss at this checkpoint")
|
||||
display_name: str = Field(
|
||||
..., description = "User-friendly checkpoint name (folder name)"
|
||||
)
|
||||
path: str = Field(..., description = "Full path to the checkpoint directory")
|
||||
loss: Optional[float] = Field(None, description = "Training loss at this checkpoint")
|
||||
|
||||
|
||||
class ModelCheckpoints(BaseModel):
|
||||
"""A training run and its associated checkpoints."""
|
||||
|
||||
name: str = Field(..., description="Training run folder name")
|
||||
name: str = Field(..., description = "Training run folder name")
|
||||
checkpoints: List[CheckpointInfo] = Field(
|
||||
default_factory=list,
|
||||
description="List of checkpoints for this training run (final + intermediate)",
|
||||
default_factory = list,
|
||||
description = "List of checkpoints for this training run (final + intermediate)",
|
||||
)
|
||||
base_model: Optional[str] = Field(
|
||||
None,
|
||||
description="Base model name from adapter_config.json or config.json",
|
||||
description = "Base model name from adapter_config.json or config.json",
|
||||
)
|
||||
peft_type: Optional[str] = Field(
|
||||
None,
|
||||
description="PEFT type (e.g. LORA) if adapter training, None for full fine-tune",
|
||||
description = "PEFT type (e.g. LORA) if adapter training, None for full fine-tune",
|
||||
)
|
||||
lora_rank: Optional[int] = Field(
|
||||
None,
|
||||
description="LoRA rank (r) if applicable",
|
||||
description = "LoRA rank (r) if applicable",
|
||||
)
|
||||
|
||||
|
||||
class CheckpointListResponse(BaseModel):
|
||||
"""Response for listing available checkpoints in an outputs directory."""
|
||||
|
||||
outputs_dir: str = Field(..., description="Directory that was scanned")
|
||||
outputs_dir: str = Field(..., description = "Directory that was scanned")
|
||||
models: List[ModelCheckpoints] = Field(
|
||||
default_factory=list,
|
||||
description="List of training runs with their checkpoints",
|
||||
default_factory = list,
|
||||
description = "List of training runs with their checkpoints",
|
||||
)
|
||||
|
||||
|
||||
class ModelDetails(BaseModel):
|
||||
"""Detailed model configuration and metadata - can be used for both list and detail views"""
|
||||
id: str = Field(..., description="Model identifier")
|
||||
model_name: Optional[str] = Field(None, description="Model identifier (alias for id, for backward compatibility)")
|
||||
name: Optional[str] = Field(None, description="Display name for the model")
|
||||
config: Optional[Dict[str, Any]] = Field(None, description="Model configuration dictionary")
|
||||
is_vision: bool = Field(False, description="Whether model is a vision model")
|
||||
is_embedding: bool = Field(False, description="Whether model is an embedding/sentence-transformer model")
|
||||
is_lora: bool = Field(False, description="Whether model is a LoRA adapter")
|
||||
is_gguf: bool = Field(False, description="Whether model is a GGUF model (llama.cpp format)")
|
||||
is_audio: bool = Field(False, description="Whether model is a TTS audio model")
|
||||
audio_type: Optional[str] = Field(None, description="Audio codec type: snac, csm, bicodec, dac")
|
||||
has_audio_input: bool = Field(False, description="Whether model accepts audio input (ASR)")
|
||||
model_type: Optional[ModelType] = Field(None, description="Collapsed model modality: text, vision, audio, or embeddings")
|
||||
base_model: Optional[str] = Field(None, description="Base model if this is a LoRA adapter")
|
||||
|
||||
id: str = Field(..., description = "Model identifier")
|
||||
model_name: Optional[str] = Field(
|
||||
None, description = "Model identifier (alias for id, for backward compatibility)"
|
||||
)
|
||||
name: Optional[str] = Field(None, description = "Display name for the model")
|
||||
config: Optional[Dict[str, Any]] = Field(
|
||||
None, description = "Model configuration dictionary"
|
||||
)
|
||||
is_vision: bool = Field(False, description = "Whether model is a vision model")
|
||||
is_embedding: bool = Field(
|
||||
False, description = "Whether model is an embedding/sentence-transformer model"
|
||||
)
|
||||
is_lora: bool = Field(False, description = "Whether model is a LoRA adapter")
|
||||
is_gguf: bool = Field(
|
||||
False, description = "Whether model is a GGUF model (llama.cpp format)"
|
||||
)
|
||||
is_audio: bool = Field(False, description = "Whether model is a TTS audio model")
|
||||
audio_type: Optional[str] = Field(
|
||||
None, description = "Audio codec type: snac, csm, bicodec, dac"
|
||||
)
|
||||
has_audio_input: bool = Field(
|
||||
False, description = "Whether model accepts audio input (ASR)"
|
||||
)
|
||||
model_type: Optional[ModelType] = Field(
|
||||
None, description = "Collapsed model modality: text, vision, audio, or embeddings"
|
||||
)
|
||||
base_model: Optional[str] = Field(
|
||||
None, description = "Base model if this is a LoRA adapter"
|
||||
)
|
||||
|
||||
|
||||
class LoRAInfo(BaseModel):
|
||||
"""LoRA adapter or exported model information"""
|
||||
display_name: str = Field(..., description="Display name for the LoRA")
|
||||
adapter_path: str = Field(..., description="Path to the LoRA adapter or exported model")
|
||||
base_model: Optional[str] = Field(None, description="Base model identifier")
|
||||
source: Optional[str] = Field(None, description="'training' or 'exported'")
|
||||
export_type: Optional[str] = Field(None, description="'lora', 'merged', or 'gguf' (for exports)")
|
||||
|
||||
display_name: str = Field(..., description = "Display name for the LoRA")
|
||||
adapter_path: str = Field(
|
||||
..., description = "Path to the LoRA adapter or exported model"
|
||||
)
|
||||
base_model: Optional[str] = Field(None, description = "Base model identifier")
|
||||
source: Optional[str] = Field(None, description = "'training' or 'exported'")
|
||||
export_type: Optional[str] = Field(
|
||||
None, description = "'lora', 'merged', or 'gguf' (for exports)"
|
||||
)
|
||||
|
||||
|
||||
class LoRAScanResponse(BaseModel):
|
||||
"""Response schema for scanning trained LoRA adapters"""
|
||||
loras: List[LoRAInfo] = Field(default_factory=list, description="List of found LoRA adapters")
|
||||
outputs_dir: str = Field(..., description="Directory that was scanned")
|
||||
|
||||
loras: List[LoRAInfo] = Field(
|
||||
default_factory = list, description = "List of found LoRA adapters"
|
||||
)
|
||||
outputs_dir: str = Field(..., description = "Directory that was scanned")
|
||||
|
||||
|
||||
class ModelListResponse(BaseModel):
|
||||
"""Response schema for listing models"""
|
||||
models: List[ModelDetails] = Field(default_factory=list, description="List of models")
|
||||
default_models: List[str] = Field(default_factory=list, description="List of default model IDs")
|
||||
|
||||
models: List[ModelDetails] = Field(
|
||||
default_factory = list, description = "List of models"
|
||||
)
|
||||
default_models: List[str] = Field(
|
||||
default_factory = list, description = "List of default model IDs"
|
||||
)
|
||||
|
||||
|
||||
class GgufVariantDetail(BaseModel):
|
||||
"""A single GGUF quantization variant in a HuggingFace repo."""
|
||||
filename: str = Field(..., description="GGUF filename (e.g., 'gemma-3-4b-it-Q4_K_M.gguf')")
|
||||
quant: str = Field(..., description="Quantization label (e.g., 'Q4_K_M')")
|
||||
size_bytes: int = Field(0, description="File size in bytes")
|
||||
|
||||
filename: str = Field(
|
||||
..., description = "GGUF filename (e.g., 'gemma-3-4b-it-Q4_K_M.gguf')"
|
||||
)
|
||||
quant: str = Field(..., description = "Quantization label (e.g., 'Q4_K_M')")
|
||||
size_bytes: int = Field(0, description = "File size in bytes")
|
||||
|
||||
|
||||
class GgufVariantsResponse(BaseModel):
|
||||
"""Response for listing GGUF quantization variants in a HuggingFace repo."""
|
||||
repo_id: str = Field(..., description="HuggingFace repo ID")
|
||||
variants: List[GgufVariantDetail] = Field(default_factory=list, description="Available GGUF variants")
|
||||
has_vision: bool = Field(False, description="Whether the model has vision support (mmproj files)")
|
||||
default_variant: Optional[str] = Field(None, description="Recommended default quantization variant")
|
||||
|
||||
repo_id: str = Field(..., description = "HuggingFace repo ID")
|
||||
variants: List[GgufVariantDetail] = Field(
|
||||
default_factory = list, description = "Available GGUF variants"
|
||||
)
|
||||
has_vision: bool = Field(
|
||||
False, description = "Whether the model has vision support (mmproj files)"
|
||||
)
|
||||
default_variant: Optional[str] = Field(
|
||||
None, description = "Recommended default quantization variant"
|
||||
)
|
||||
|
||||
|
||||
class LocalModelInfo(BaseModel):
|
||||
"""Discovered local model candidate."""
|
||||
id: str = Field(..., description="Identifier to use for loading/training")
|
||||
display_name: str = Field(..., description="Display label")
|
||||
path: str = Field(..., description="Local path where model data was discovered")
|
||||
|
||||
id: str = Field(..., description = "Identifier to use for loading/training")
|
||||
display_name: str = Field(..., description = "Display label")
|
||||
path: str = Field(..., description = "Local path where model data was discovered")
|
||||
source: Literal["models_dir", "hf_cache"] = Field(
|
||||
...,
|
||||
description="Discovery source",
|
||||
description = "Discovery source",
|
||||
)
|
||||
model_id: Optional[str] = Field(
|
||||
None,
|
||||
description="HF repo id for cached models, e.g. org/model",
|
||||
description = "HF repo id for cached models, e.g. org/model",
|
||||
)
|
||||
updated_at: Optional[float] = Field(
|
||||
None,
|
||||
description="Unix timestamp of latest observed update",
|
||||
description = "Unix timestamp of latest observed update",
|
||||
)
|
||||
|
||||
|
||||
class LocalModelListResponse(BaseModel):
|
||||
"""Response schema for listing local/cached models."""
|
||||
models_dir: str = Field(..., description="Directory scanned for custom local models")
|
||||
|
||||
models_dir: str = Field(
|
||||
..., description = "Directory scanned for custom local models"
|
||||
)
|
||||
hf_cache_dir: Optional[str] = Field(
|
||||
None,
|
||||
description="HF cache root that was scanned",
|
||||
description = "HF cache root that was scanned",
|
||||
)
|
||||
models: List[LocalModelInfo] = Field(
|
||||
default_factory=list,
|
||||
description="Discovered local/cached models",
|
||||
default_factory = list,
|
||||
description = "Discovered local/cached models",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,45 +5,63 @@
|
|||
Pydantic response schemas for endpoints that previously returned raw dicts.
|
||||
These are small response models for training and model management routes.
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
# --- Training route response models ---
|
||||
|
||||
|
||||
class TrainingStopResponse(BaseModel):
|
||||
"""Response for stopping a training job"""
|
||||
status: str = Field(..., description="Current status: 'stopped' or 'idle'")
|
||||
message: str = Field(..., description="Human-readable status message")
|
||||
|
||||
status: str = Field(..., description = "Current status: 'stopped' or 'idle'")
|
||||
message: str = Field(..., description = "Human-readable status message")
|
||||
|
||||
|
||||
class TrainingMetricsResponse(BaseModel):
|
||||
"""Response for training metrics history"""
|
||||
loss_history: List[float] = Field(default_factory=list, description="Loss values per step")
|
||||
lr_history: List[float] = Field(default_factory=list, description="Learning rate per step")
|
||||
step_history: List[int] = Field(default_factory=list, description="Step numbers")
|
||||
grad_norm_history: List[float] = Field(default_factory=list, description="Gradient norm values")
|
||||
grad_norm_step_history: List[int] = Field(default_factory=list, description="Step numbers for gradient norm values")
|
||||
current_loss: Optional[float] = Field(None, description="Most recent loss value")
|
||||
current_lr: Optional[float] = Field(None, description="Most recent learning rate")
|
||||
current_step: Optional[int] = Field(None, description="Most recent step number")
|
||||
|
||||
loss_history: List[float] = Field(
|
||||
default_factory = list, description = "Loss values per step"
|
||||
)
|
||||
lr_history: List[float] = Field(
|
||||
default_factory = list, description = "Learning rate per step"
|
||||
)
|
||||
step_history: List[int] = Field(default_factory = list, description = "Step numbers")
|
||||
grad_norm_history: List[float] = Field(
|
||||
default_factory = list, description = "Gradient norm values"
|
||||
)
|
||||
grad_norm_step_history: List[int] = Field(
|
||||
default_factory = list, description = "Step numbers for gradient norm values"
|
||||
)
|
||||
current_loss: Optional[float] = Field(None, description = "Most recent loss value")
|
||||
current_lr: Optional[float] = Field(None, description = "Most recent learning rate")
|
||||
current_step: Optional[int] = Field(None, description = "Most recent step number")
|
||||
|
||||
|
||||
# --- Model management route response models ---
|
||||
|
||||
|
||||
class LoRABaseModelResponse(BaseModel):
|
||||
"""Response for getting a LoRA's base model"""
|
||||
lora_path: str = Field(..., description="Path to the LoRA adapter")
|
||||
base_model: str = Field(..., description="Base model identifier")
|
||||
|
||||
lora_path: str = Field(..., description = "Path to the LoRA adapter")
|
||||
base_model: str = Field(..., description = "Base model identifier")
|
||||
|
||||
|
||||
class VisionCheckResponse(BaseModel):
|
||||
"""Response for checking if a model is a vision model"""
|
||||
model_name: str = Field(..., description="Model identifier")
|
||||
is_vision: bool = Field(..., description="Whether the model is a vision model")
|
||||
|
||||
model_name: str = Field(..., description = "Model identifier")
|
||||
is_vision: bool = Field(..., description = "Whether the model is a vision model")
|
||||
|
||||
|
||||
class EmbeddingCheckResponse(BaseModel):
|
||||
"""Response for checking if a model is an embedding model"""
|
||||
model_name: str = Field(..., description="Model identifier")
|
||||
is_embedding: bool = Field(..., description="Whether the model is an embedding/sentence-transformer model")
|
||||
|
||||
model_name: str = Field(..., description = "Model identifier")
|
||||
is_embedding: bool = Field(
|
||||
..., description = "Whether the model is an embedding/sentence-transformer model"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,44 +4,63 @@
|
|||
"""
|
||||
Pydantic schemas for Training API
|
||||
"""
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from typing import Any, Optional, List, Dict, Literal
|
||||
|
||||
|
||||
class TrainingStartRequest(BaseModel):
|
||||
"""Request schema for starting training"""
|
||||
|
||||
# Model parameters
|
||||
model_name: str = Field(..., description="Model identifier (e.g., 'unsloth/llama-3-8b-bnb-4bit')")
|
||||
training_type: str = Field(..., description="Training type: 'LoRA/QLoRA' or 'Full Finetuning'")
|
||||
hf_token: Optional[str] = Field(None, description="HuggingFace token")
|
||||
load_in_4bit: bool = Field(True, description="Load model in 4-bit quantization")
|
||||
max_seq_length: int = Field(2048, description="Maximum sequence length")
|
||||
model_name: str = Field(
|
||||
..., description = "Model identifier (e.g., 'unsloth/llama-3-8b-bnb-4bit')"
|
||||
)
|
||||
training_type: str = Field(
|
||||
..., description = "Training type: 'LoRA/QLoRA' or 'Full Finetuning'"
|
||||
)
|
||||
hf_token: Optional[str] = Field(None, description = "HuggingFace token")
|
||||
load_in_4bit: bool = Field(True, description = "Load model in 4-bit quantization")
|
||||
max_seq_length: int = Field(2048, description = "Maximum sequence length")
|
||||
trust_remote_code: bool = Field(
|
||||
False,
|
||||
description="Allow loading models with custom code (e.g. NVIDIA Nemotron). Only enable for repos you trust.",
|
||||
description = "Allow loading models with custom code (e.g. NVIDIA Nemotron). Only enable for repos you trust.",
|
||||
)
|
||||
|
||||
# Dataset parameters
|
||||
hf_dataset: Optional[str] = Field(None, description="HuggingFace dataset identifier")
|
||||
local_datasets: List[str] = Field(default_factory=list, description="List of local dataset paths")
|
||||
format_type: str = Field(..., description="Dataset format type")
|
||||
hf_dataset: Optional[str] = Field(
|
||||
None, description = "HuggingFace dataset identifier"
|
||||
)
|
||||
local_datasets: List[str] = Field(
|
||||
default_factory = list, description = "List of local dataset paths"
|
||||
)
|
||||
format_type: str = Field(..., description = "Dataset format type")
|
||||
subset: Optional[str] = None
|
||||
train_split: Optional[str] = Field("train", description="Training split name")
|
||||
eval_split: Optional[str] = Field(None, description="Eval split name. None = auto-detect")
|
||||
eval_steps: float = Field(0.00, description="Fraction of total steps between evals (0-1)")
|
||||
dataset_slice_start: Optional[int] = Field(None, description="Inclusive start row index for dataset slicing")
|
||||
dataset_slice_end: Optional[int] = Field(None, description="Inclusive end row index for dataset slicing")
|
||||
train_split: Optional[str] = Field("train", description = "Training split name")
|
||||
eval_split: Optional[str] = Field(
|
||||
None, description = "Eval split name. None = auto-detect"
|
||||
)
|
||||
eval_steps: float = Field(
|
||||
0.00, description = "Fraction of total steps between evals (0-1)"
|
||||
)
|
||||
dataset_slice_start: Optional[int] = Field(
|
||||
None, description = "Inclusive start row index for dataset slicing"
|
||||
)
|
||||
dataset_slice_end: Optional[int] = Field(
|
||||
None, description = "Inclusive end row index for dataset slicing"
|
||||
)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@model_validator(mode = "before")
|
||||
@classmethod
|
||||
def _compat_split(cls, values: Any) -> Any:
|
||||
"""Accept legacy 'split' field as alias for 'train_split'."""
|
||||
if isinstance(values, dict) and "split" in values:
|
||||
values.setdefault("train_split", values.pop("split"))
|
||||
return values
|
||||
|
||||
custom_format_mapping: Optional[Dict[str, Any]] = Field(
|
||||
None,
|
||||
description=(
|
||||
description = (
|
||||
"User-provided column-to-role mapping, e.g. {'image': 'image', 'caption': 'text'} "
|
||||
"for VLM or {'instruction': 'user', 'output': 'assistant'} for LLM. "
|
||||
"Enhanced format includes __system_prompt, __user_template, "
|
||||
|
|
@ -49,59 +68,77 @@ class TrainingStartRequest(BaseModel):
|
|||
),
|
||||
)
|
||||
# Training parameters
|
||||
num_epochs: int = Field(1, description="Number of training epochs")
|
||||
learning_rate: str = Field("2e-4", description="Learning rate")
|
||||
batch_size: int = Field(1, description="Batch size")
|
||||
gradient_accumulation_steps: int = Field(1, description="Gradient accumulation steps")
|
||||
warmup_steps: Optional[int] = Field(None, description="Warmup steps")
|
||||
warmup_ratio: Optional[float] = Field(None, description="Warmup ratio")
|
||||
max_steps: Optional[int] = Field(None, description="Maximum training steps")
|
||||
save_steps: int = Field(100, description="Steps between checkpoints")
|
||||
weight_decay: float = Field(0.01, description="Weight decay")
|
||||
random_seed: int = Field(42, description="Random seed")
|
||||
packing: bool = Field(False, description="Enable sequence packing")
|
||||
optim: str = Field("adamw_8bit", description="Optimizer")
|
||||
lr_scheduler_type: str = Field("linear", description="Learning rate scheduler type")
|
||||
num_epochs: int = Field(1, description = "Number of training epochs")
|
||||
learning_rate: str = Field("2e-4", description = "Learning rate")
|
||||
batch_size: int = Field(1, description = "Batch size")
|
||||
gradient_accumulation_steps: int = Field(
|
||||
1, description = "Gradient accumulation steps"
|
||||
)
|
||||
warmup_steps: Optional[int] = Field(None, description = "Warmup steps")
|
||||
warmup_ratio: Optional[float] = Field(None, description = "Warmup ratio")
|
||||
max_steps: Optional[int] = Field(None, description = "Maximum training steps")
|
||||
save_steps: int = Field(100, description = "Steps between checkpoints")
|
||||
weight_decay: float = Field(0.01, description = "Weight decay")
|
||||
random_seed: int = Field(42, description = "Random seed")
|
||||
packing: bool = Field(False, description = "Enable sequence packing")
|
||||
optim: str = Field("adamw_8bit", description = "Optimizer")
|
||||
lr_scheduler_type: str = Field("linear", description = "Learning rate scheduler type")
|
||||
|
||||
# LoRA parameters
|
||||
use_lora: bool = Field(True, description="Use LoRA (derived from training_type)")
|
||||
lora_r: int = Field(16, description="LoRA rank")
|
||||
lora_alpha: int = Field(16, description="LoRA alpha")
|
||||
lora_dropout: float = Field(0.0, description="LoRA dropout")
|
||||
target_modules: List[str] = Field(default_factory=list, description="Target modules for LoRA")
|
||||
gradient_checkpointing: str = Field("", description="Gradient checkpointing setting")
|
||||
use_rslora: bool = Field(False, description="Use RSLoRA")
|
||||
use_loftq: bool = Field(False, description="Use LoftQ")
|
||||
train_on_completions: bool = Field(False, description="Train on completions only")
|
||||
use_lora: bool = Field(True, description = "Use LoRA (derived from training_type)")
|
||||
lora_r: int = Field(16, description = "LoRA rank")
|
||||
lora_alpha: int = Field(16, description = "LoRA alpha")
|
||||
lora_dropout: float = Field(0.0, description = "LoRA dropout")
|
||||
target_modules: List[str] = Field(
|
||||
default_factory = list, description = "Target modules for LoRA"
|
||||
)
|
||||
gradient_checkpointing: str = Field(
|
||||
"", description = "Gradient checkpointing setting"
|
||||
)
|
||||
use_rslora: bool = Field(False, description = "Use RSLoRA")
|
||||
use_loftq: bool = Field(False, description = "Use LoftQ")
|
||||
train_on_completions: bool = Field(False, description = "Train on completions only")
|
||||
|
||||
# Vision-specific LoRA parameters
|
||||
finetune_vision_layers: bool = Field(False, description="Finetune vision layers")
|
||||
finetune_language_layers: bool = Field(False, description="Finetune language layers")
|
||||
finetune_attention_modules: bool = Field(False, description="Finetune attention modules")
|
||||
finetune_mlp_modules: bool = Field(False, description="Finetune MLP modules")
|
||||
is_dataset_image: bool = Field(False, description="Whether the dataset contains image data")
|
||||
is_dataset_audio: bool = Field(False, description="Whether the dataset contains audio data")
|
||||
is_embedding: bool = Field(False, description="Whether model is an embedding/sentence-transformer model")
|
||||
finetune_vision_layers: bool = Field(False, description = "Finetune vision layers")
|
||||
finetune_language_layers: bool = Field(
|
||||
False, description = "Finetune language layers"
|
||||
)
|
||||
finetune_attention_modules: bool = Field(
|
||||
False, description = "Finetune attention modules"
|
||||
)
|
||||
finetune_mlp_modules: bool = Field(False, description = "Finetune MLP modules")
|
||||
is_dataset_image: bool = Field(
|
||||
False, description = "Whether the dataset contains image data"
|
||||
)
|
||||
is_dataset_audio: bool = Field(
|
||||
False, description = "Whether the dataset contains audio data"
|
||||
)
|
||||
is_embedding: bool = Field(
|
||||
False, description = "Whether model is an embedding/sentence-transformer model"
|
||||
)
|
||||
|
||||
# Logging parameters
|
||||
enable_wandb: bool = Field(False, description="Enable Weights & Biases logging")
|
||||
wandb_token: Optional[str] = Field(None, description="W&B token")
|
||||
wandb_project: Optional[str] = Field(None, description="W&B project name")
|
||||
enable_tensorboard: bool = Field(False, description="Enable TensorBoard logging")
|
||||
tensorboard_dir: Optional[str] = Field(None, description="TensorBoard directory")
|
||||
enable_wandb: bool = Field(False, description = "Enable Weights & Biases logging")
|
||||
wandb_token: Optional[str] = Field(None, description = "W&B token")
|
||||
wandb_project: Optional[str] = Field(None, description = "W&B project name")
|
||||
enable_tensorboard: bool = Field(False, description = "Enable TensorBoard logging")
|
||||
tensorboard_dir: Optional[str] = Field(None, description = "TensorBoard directory")
|
||||
|
||||
|
||||
class TrainingJobResponse(BaseModel):
|
||||
"""Immediate response when training is initiated"""
|
||||
job_id: str = Field(..., description="Unique training job identifier")
|
||||
status: Literal["queued", "error"] = Field(..., description="Initial job status")
|
||||
message: str = Field(..., description="Human-readable status message")
|
||||
error: Optional[str] = Field(None, description="Error details if status is 'error'")
|
||||
|
||||
job_id: str = Field(..., description = "Unique training job identifier")
|
||||
status: Literal["queued", "error"] = Field(..., description = "Initial job status")
|
||||
message: str = Field(..., description = "Human-readable status message")
|
||||
error: Optional[str] = Field(None, description = "Error details if status is 'error'")
|
||||
|
||||
|
||||
class TrainingStatus(BaseModel):
|
||||
"""Current training job status - works for streaming or polling"""
|
||||
job_id: str = Field(..., description="Training job identifier")
|
||||
|
||||
job_id: str = Field(..., description = "Training job identifier")
|
||||
phase: Literal[
|
||||
"idle",
|
||||
"loading_model",
|
||||
|
|
@ -110,31 +147,49 @@ class TrainingStatus(BaseModel):
|
|||
"training",
|
||||
"completed",
|
||||
"error",
|
||||
"stopped"
|
||||
] = Field(..., description="Current phase of training pipeline")
|
||||
is_training_running: bool = Field(..., description="True if training loop is actively running")
|
||||
eval_enabled: bool = Field(False, description="True if evaluation dataset is configured for this training run")
|
||||
message: str = Field(..., description="Human-readable status message")
|
||||
error: Optional[str] = Field(None, description="Error details if phase is 'error'")
|
||||
details: Optional[dict] = Field(None, description="Phase-specific info, e.g. {'model_size': '8B'}")
|
||||
"stopped",
|
||||
] = Field(..., description = "Current phase of training pipeline")
|
||||
is_training_running: bool = Field(
|
||||
..., description = "True if training loop is actively running"
|
||||
)
|
||||
eval_enabled: bool = Field(
|
||||
False,
|
||||
description = "True if evaluation dataset is configured for this training run",
|
||||
)
|
||||
message: str = Field(..., description = "Human-readable status message")
|
||||
error: Optional[str] = Field(None, description = "Error details if phase is 'error'")
|
||||
details: Optional[dict] = Field(
|
||||
None, description = "Phase-specific info, e.g. {'model_size': '8B'}"
|
||||
)
|
||||
metric_history: Optional[dict] = Field(
|
||||
None,
|
||||
description="Full metric history arrays for chart recovery after SSE reconnection. "
|
||||
"Keys: 'steps', 'loss', 'lr', 'grad_norm', 'grad_norm_steps' — each a list of numeric values.",
|
||||
description = "Full metric history arrays for chart recovery after SSE reconnection. "
|
||||
"Keys: 'steps', 'loss', 'lr', 'grad_norm', 'grad_norm_steps' — each a list of numeric values.",
|
||||
)
|
||||
|
||||
|
||||
class TrainingProgress(BaseModel):
|
||||
"""Training progress metrics - for streaming or polling"""
|
||||
job_id: str = Field(..., description="Training job identifier")
|
||||
step: int = Field(..., description="Current training step")
|
||||
total_steps: int = Field(..., description="Total training steps")
|
||||
loss: float = Field(..., description="Current loss value")
|
||||
learning_rate: float = Field(..., description="Current learning rate")
|
||||
progress_percent: float = Field(..., description="Progress percentage (0.0 to 100.0)")
|
||||
epoch: Optional[float] = Field(None, description="Current epoch")
|
||||
elapsed_seconds: Optional[float] = Field(None, description="Time elapsed since training started")
|
||||
eta_seconds: Optional[float] = Field(None, description="Estimated time remaining")
|
||||
grad_norm: Optional[float] = Field(None, description="L2 norm of gradients, computed before gradient clipping")
|
||||
num_tokens: Optional[int] = Field(None, description="Total number of tokens processed so far")
|
||||
eval_loss: Optional[float] = Field(None, description="Eval loss from the most recent evaluation step")
|
||||
|
||||
job_id: str = Field(..., description = "Training job identifier")
|
||||
step: int = Field(..., description = "Current training step")
|
||||
total_steps: int = Field(..., description = "Total training steps")
|
||||
loss: float = Field(..., description = "Current loss value")
|
||||
learning_rate: float = Field(..., description = "Current learning rate")
|
||||
progress_percent: float = Field(
|
||||
..., description = "Progress percentage (0.0 to 100.0)"
|
||||
)
|
||||
epoch: Optional[float] = Field(None, description = "Current epoch")
|
||||
elapsed_seconds: Optional[float] = Field(
|
||||
None, description = "Time elapsed since training started"
|
||||
)
|
||||
eta_seconds: Optional[float] = Field(None, description = "Estimated time remaining")
|
||||
grad_norm: Optional[float] = Field(
|
||||
None, description = "L2 norm of gradients, computed before gradient clipping"
|
||||
)
|
||||
num_tokens: Optional[int] = Field(
|
||||
None, description = "Total number of tokens processed so far"
|
||||
)
|
||||
eval_loss: Optional[float] = Field(
|
||||
None, description = "Eval loss from the most recent evaluation step"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ from pydantic import BaseModel, Field
|
|||
class Token(BaseModel):
|
||||
"""Authentication token model with access and refresh tokens."""
|
||||
|
||||
access_token: str = Field(..., description="JWT access token (60 min expiry)")
|
||||
refresh_token: str = Field(..., description="Opaque refresh token (7 day expiry)")
|
||||
token_type: str = Field(..., description="Token type, always 'bearer'")
|
||||
|
||||
access_token: str = Field(..., description = "JWT access token (60 min expiry)")
|
||||
refresh_token: str = Field(..., description = "Opaque refresh token (7 day expiry)")
|
||||
token_type: str = Field(..., description = "Token type, always 'bearer'")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
|
@ -1,3 +1,6 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=68", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
|
|
|||
|
|
@ -36,9 +36,9 @@ def build_unstructured_preview_rows(
|
|||
chunk_overlap: Any,
|
||||
) -> list[dict[str, str]]:
|
||||
parquet_path, rows = materialize_unstructured_seed_dataset(
|
||||
source_path=source_path,
|
||||
chunk_size=chunk_size,
|
||||
chunk_overlap=chunk_overlap,
|
||||
source_path = source_path,
|
||||
chunk_size = chunk_size,
|
||||
chunk_overlap = chunk_overlap,
|
||||
)
|
||||
count = max(0, int(preview_size))
|
||||
if rows:
|
||||
|
|
@ -47,12 +47,14 @@ def build_unstructured_preview_rows(
|
|||
try:
|
||||
import pandas as pd
|
||||
except ImportError as exc: # pragma: no cover
|
||||
raise RuntimeError(f"pandas is required for unstructured seed processing: {exc}") from exc
|
||||
raise RuntimeError(
|
||||
f"pandas is required for unstructured seed processing: {exc}"
|
||||
) from exc
|
||||
|
||||
dataframe = pd.read_parquet(parquet_path).head(count)
|
||||
return [
|
||||
{"chunk_text": str(value.get("chunk_text", "")).strip()}
|
||||
for value in dataframe.to_dict(orient="records")
|
||||
for value in dataframe.to_dict(orient = "records")
|
||||
if str(value.get("chunk_text", "")).strip()
|
||||
]
|
||||
|
||||
|
|
@ -69,9 +71,9 @@ def materialize_unstructured_seed_dataset(
|
|||
|
||||
size, overlap = resolve_chunking(chunk_size, chunk_overlap)
|
||||
key = _compute_cache_key(
|
||||
source_path=resolved,
|
||||
chunk_size=size,
|
||||
chunk_overlap=overlap,
|
||||
source_path = resolved,
|
||||
chunk_size = size,
|
||||
chunk_overlap = overlap,
|
||||
)
|
||||
parquet_path = _CACHE_DIR / f"{key}.parquet"
|
||||
if parquet_path.exists():
|
||||
|
|
@ -79,9 +81,9 @@ def materialize_unstructured_seed_dataset(
|
|||
|
||||
text = load_unstructured_text_file(resolved)
|
||||
chunks = split_text_into_chunks(
|
||||
text=text,
|
||||
chunk_size=size,
|
||||
chunk_overlap=overlap,
|
||||
text = text,
|
||||
chunk_size = size,
|
||||
chunk_overlap = overlap,
|
||||
)
|
||||
if not chunks:
|
||||
raise ValueError("No text found in unstructured seed source.")
|
||||
|
|
@ -91,10 +93,12 @@ def materialize_unstructured_seed_dataset(
|
|||
try:
|
||||
import pandas as pd
|
||||
except ImportError as exc: # pragma: no cover
|
||||
raise RuntimeError(f"pandas is required for unstructured seed processing: {exc}") from exc
|
||||
raise RuntimeError(
|
||||
f"pandas is required for unstructured seed processing: {exc}"
|
||||
) from exc
|
||||
|
||||
tmp_path = _CACHE_DIR / f"{key}.tmp.parquet"
|
||||
pd.DataFrame(rows).to_parquet(tmp_path, index=False)
|
||||
pd.DataFrame(rows).to_parquet(tmp_path, index = False)
|
||||
tmp_path.replace(parquet_path)
|
||||
return parquet_path, rows
|
||||
|
||||
|
|
@ -104,7 +108,7 @@ def load_unstructured_text_file(path: Path) -> str:
|
|||
if ext not in {".txt", ".md"}:
|
||||
raise ValueError(f"Unsupported unstructured seed file type: {ext}")
|
||||
|
||||
raw = path.read_text(encoding="utf-8", errors="ignore")
|
||||
raw = path.read_text(encoding = "utf-8", errors = "ignore")
|
||||
return normalize_unstructured_text(raw)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -15,11 +15,11 @@ from .chunking import DEFAULT_CHUNK_OVERLAP, DEFAULT_CHUNK_SIZE, resolve_chunkin
|
|||
|
||||
class UnstructuredSeedSource(SeedSource):
|
||||
seed_type: Literal["unstructured"] = "unstructured"
|
||||
path: str = Field(..., min_length=1)
|
||||
path: str = Field(..., min_length = 1)
|
||||
chunk_size: int = DEFAULT_CHUNK_SIZE
|
||||
chunk_overlap: int = DEFAULT_CHUNK_OVERLAP
|
||||
|
||||
@field_validator("path", mode="after")
|
||||
@field_validator("path", mode = "after")
|
||||
@classmethod
|
||||
def _validate_path(cls, value: str) -> str:
|
||||
path = Path(value).expanduser()
|
||||
|
|
@ -27,13 +27,13 @@ class UnstructuredSeedSource(SeedSource):
|
|||
raise ValueError(f"Unstructured seed path is not a file: {path}")
|
||||
return value
|
||||
|
||||
@field_validator("chunk_size", mode="after")
|
||||
@field_validator("chunk_size", mode = "after")
|
||||
@classmethod
|
||||
def _validate_chunk_size(cls, value: int) -> int:
|
||||
size, _ = resolve_chunking(value, 0)
|
||||
return size
|
||||
|
||||
@field_validator("chunk_overlap", mode="after")
|
||||
@field_validator("chunk_overlap", mode = "after")
|
||||
@classmethod
|
||||
def _validate_chunk_overlap(cls, value: int, info) -> int:
|
||||
size = info.data.get("chunk_size", cls.model_fields["chunk_size"].default)
|
||||
|
|
|
|||
|
|
@ -18,8 +18,8 @@ class UnstructuredSeedReader(SeedReader[UnstructuredSeedSource]):
|
|||
|
||||
def get_dataset_uri(self) -> str:
|
||||
path, _ = materialize_unstructured_seed_dataset(
|
||||
source_path=Path(self.source.path),
|
||||
chunk_size=self.source.chunk_size,
|
||||
chunk_overlap=self.source.chunk_overlap,
|
||||
source_path = Path(self.source.path),
|
||||
chunk_size = self.source.chunk_size,
|
||||
chunk_overlap = self.source.chunk_overlap,
|
||||
)
|
||||
return str(path)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
from data_designer.plugins.plugin import Plugin, PluginType
|
||||
|
||||
unstructured_seed_plugin = Plugin(
|
||||
impl_qualified_name="data_designer_unstructured_seed.impl.UnstructuredSeedReader",
|
||||
config_qualified_name="data_designer_unstructured_seed.config.UnstructuredSeedSource",
|
||||
plugin_type=PluginType.SEED_READER,
|
||||
impl_qualified_name = "data_designer_unstructured_seed.impl.UnstructuredSeedReader",
|
||||
config_qualified_name = "data_designer_unstructured_seed.config.UnstructuredSeedSource",
|
||||
plugin_type = PluginType.SEED_READER,
|
||||
)
|
||||
|
|
|
|||
2
studio/backend/requirements/__init__.py
Normal file
2
studio/backend/requirements/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
#!/usr/bin/env python3
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
||||
"""Relax strict metadata pins so pip check matches known working single-env stack.
|
||||
|
||||
Why:
|
||||
|
|
@ -48,13 +49,13 @@ def metadata_path(dist_name: str) -> Path | None:
|
|||
|
||||
|
||||
def patch_file(path: Path) -> bool:
|
||||
original = path.read_text(encoding="utf-8")
|
||||
original = path.read_text(encoding = "utf-8")
|
||||
updated = original
|
||||
for pattern, repl in PATCHES:
|
||||
updated = pattern.sub(repl, updated)
|
||||
if updated == original:
|
||||
return False
|
||||
path.write_text(updated, encoding="utf-8")
|
||||
path.write_text(updated, encoding = "utf-8")
|
||||
return True
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"""
|
||||
Authentication API routes
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
import secrets
|
||||
|
||||
|
|
@ -25,7 +26,7 @@ from auth.authentication import (
|
|||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/status", response_model=AuthStatusResponse)
|
||||
@router.get("/status", response_model = AuthStatusResponse)
|
||||
async def auth_status() -> AuthStatusResponse:
|
||||
"""
|
||||
Check whether auth has already been initialized.
|
||||
|
|
@ -33,10 +34,10 @@ async def auth_status() -> AuthStatusResponse:
|
|||
- initialized = False -> frontend should show "Set admin password" screen.
|
||||
- initialized = True -> frontend should show normal login.
|
||||
"""
|
||||
return AuthStatusResponse(initialized=storage.is_initialized())
|
||||
return AuthStatusResponse(initialized = storage.is_initialized())
|
||||
|
||||
|
||||
@router.post("/setup", response_model=Token, status_code=status.HTTP_201_CREATED)
|
||||
@router.post("/setup", response_model = Token, status_code = status.HTTP_201_CREATED)
|
||||
async def setup_auth(payload: AuthSetupRequest) -> Token:
|
||||
"""
|
||||
First-time setup: create the admin user and a JWT secret.
|
||||
|
|
@ -46,15 +47,15 @@ async def setup_auth(payload: AuthSetupRequest) -> Token:
|
|||
"""
|
||||
if storage.is_initialized():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Auth is already initialized.",
|
||||
status_code = status.HTTP_400_BAD_REQUEST,
|
||||
detail = "Auth is already initialized.",
|
||||
)
|
||||
|
||||
# Validate the one-time setup token
|
||||
if not storage.consume_setup_token(payload.setup_token):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Invalid or expired setup token.",
|
||||
status_code = status.HTTP_403_FORBIDDEN,
|
||||
detail = "Invalid or expired setup token.",
|
||||
)
|
||||
|
||||
# Generate a strong random JWT secret for this installation
|
||||
|
|
@ -63,34 +64,34 @@ async def setup_auth(payload: AuthSetupRequest) -> Token:
|
|||
# Create user + generate tokens atomically — rollback if anything fails
|
||||
try:
|
||||
storage.create_initial_user(
|
||||
username=payload.username,
|
||||
password=payload.password,
|
||||
jwt_secret=jwt_secret,
|
||||
username = payload.username,
|
||||
password = payload.password,
|
||||
jwt_secret = jwt_secret,
|
||||
)
|
||||
|
||||
# Reload JWT secret from DB (so authentication.py picks it up)
|
||||
reload_secret()
|
||||
|
||||
# Issue access + refresh tokens for the new user
|
||||
access_token = create_access_token(subject=payload.username)
|
||||
refresh_token = create_refresh_token(subject=payload.username)
|
||||
access_token = create_access_token(subject = payload.username)
|
||||
refresh_token = create_refresh_token(subject = payload.username)
|
||||
|
||||
except Exception as e:
|
||||
# Rollback: remove the user row so setup can be retried
|
||||
storage.delete_user(payload.username)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Setup failed (rolled back): {str(e)}",
|
||||
status_code = status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail = f"Setup failed (rolled back): {str(e)}",
|
||||
)
|
||||
|
||||
return Token(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
token_type="bearer",
|
||||
access_token = access_token,
|
||||
refresh_token = refresh_token,
|
||||
token_type = "bearer",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/login", response_model=Token)
|
||||
@router.post("/login", response_model = Token)
|
||||
async def login(payload: AuthLoginRequest) -> Token:
|
||||
"""
|
||||
Login with username/password and receive access + refresh tokens.
|
||||
|
|
@ -98,27 +99,27 @@ async def login(payload: AuthLoginRequest) -> Token:
|
|||
record = storage.get_user_and_secret(payload.username)
|
||||
if record is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
status_code = status.HTTP_401_UNAUTHORIZED,
|
||||
detail = "Incorrect username or password",
|
||||
)
|
||||
|
||||
salt, pwd_hash, _jwt_secret = record
|
||||
if not hashing.verify_password(payload.password, salt, pwd_hash):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Incorrect username or password",
|
||||
status_code = status.HTTP_401_UNAUTHORIZED,
|
||||
detail = "Incorrect username or password",
|
||||
)
|
||||
|
||||
access_token = create_access_token(subject=payload.username)
|
||||
refresh_token = create_refresh_token(subject=payload.username)
|
||||
access_token = create_access_token(subject = payload.username)
|
||||
refresh_token = create_refresh_token(subject = payload.username)
|
||||
return Token(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
token_type="bearer",
|
||||
access_token = access_token,
|
||||
refresh_token = refresh_token,
|
||||
token_type = "bearer",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/refresh", response_model=Token)
|
||||
@router.post("/refresh", response_model = Token)
|
||||
async def refresh(payload: RefreshTokenRequest) -> Token:
|
||||
"""
|
||||
Exchange a valid refresh token for a new access token.
|
||||
|
|
@ -128,13 +129,12 @@ async def refresh(payload: RefreshTokenRequest) -> Token:
|
|||
new_access_token = refresh_access_token(payload.refresh_token)
|
||||
if new_access_token is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid or expired refresh token",
|
||||
status_code = status.HTTP_401_UNAUTHORIZED,
|
||||
detail = "Invalid or expired refresh token",
|
||||
)
|
||||
|
||||
return Token(
|
||||
access_token=new_access_token,
|
||||
refresh_token=payload.refresh_token,
|
||||
token_type="bearer",
|
||||
access_token = new_access_token,
|
||||
refresh_token = payload.refresh_token,
|
||||
token_type = "bearer",
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ from .mcp import router as mcp_router
|
|||
from .seed import router as seed_router
|
||||
from .validate import router as validate_router
|
||||
|
||||
router = APIRouter(dependencies=[Depends(get_current_subject)])
|
||||
router = APIRouter(dependencies = [Depends(get_current_subject)])
|
||||
router.include_router(seed_router)
|
||||
router.include_router(validate_router)
|
||||
router.include_router(jobs_router)
|
||||
|
|
|
|||
|
|
@ -21,25 +21,30 @@ def _normalize_run_name(value: Any) -> str | None:
|
|||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, str):
|
||||
raise HTTPException(status_code=400, detail="invalid run_name: must be a string")
|
||||
raise HTTPException(
|
||||
status_code = 400, detail = "invalid run_name: must be a string"
|
||||
)
|
||||
trimmed = value.strip()
|
||||
if not trimmed:
|
||||
return None
|
||||
return trimmed[:120]
|
||||
|
||||
|
||||
@router.post("/jobs", response_class=JSONResponse, response_model=JobCreateResponse)
|
||||
@router.post("/jobs", response_class = JSONResponse, response_model = JobCreateResponse)
|
||||
def create_job(payload: RecipePayload):
|
||||
recipe = payload.recipe
|
||||
if not recipe.get("columns"):
|
||||
raise HTTPException(status_code=400, detail="Recipe must include columns.")
|
||||
raise HTTPException(status_code = 400, detail = "Recipe must include columns.")
|
||||
|
||||
run: dict[str, Any] = payload.run or {}
|
||||
run.pop("artifact_path", None)
|
||||
run.pop("dataset_name", None)
|
||||
execution_type = str(run.get("execution_type") or "full").strip().lower()
|
||||
if execution_type not in {"preview", "full"}:
|
||||
raise HTTPException(status_code=400, detail="invalid execution_type: must be 'preview' or 'full'")
|
||||
raise HTTPException(
|
||||
status_code = 400,
|
||||
detail = "invalid execution_type: must be 'preview' or 'full'",
|
||||
)
|
||||
run["execution_type"] = execution_type
|
||||
run["run_name"] = _normalize_run_name(run.get("run_name"))
|
||||
run_config_raw = run.get("run_config")
|
||||
|
|
@ -49,15 +54,17 @@ def create_job(payload: RecipePayload):
|
|||
|
||||
RunConfig.model_validate(run_config_raw)
|
||||
except (ImportError, ValidationError, TypeError, ValueError) as exc:
|
||||
raise HTTPException(status_code=400, detail=f"invalid run_config: {exc}") from exc
|
||||
raise HTTPException(
|
||||
status_code = 400, detail = f"invalid run_config: {exc}"
|
||||
) from exc
|
||||
|
||||
mgr = get_job_manager()
|
||||
try:
|
||||
job_id = mgr.start(recipe=recipe, run=run)
|
||||
job_id = mgr.start(recipe = recipe, run = run)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
raise HTTPException(status_code = 409, detail = str(exc)) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
raise HTTPException(status_code = 400, detail = str(exc)) from exc
|
||||
|
||||
return {"job_id": job_id}
|
||||
|
||||
|
|
@ -67,7 +74,7 @@ def job_status(job_id: str):
|
|||
mgr = get_job_manager()
|
||||
state = mgr.get_status(job_id)
|
||||
if state is None:
|
||||
raise HTTPException(status_code=404, detail="job not found")
|
||||
raise HTTPException(status_code = 404, detail = "job not found")
|
||||
return state
|
||||
|
||||
|
||||
|
|
@ -76,7 +83,7 @@ def current_job():
|
|||
mgr = get_job_manager()
|
||||
state = mgr.get_current_status()
|
||||
if state is None:
|
||||
raise HTTPException(status_code=404, detail="no job")
|
||||
raise HTTPException(status_code = 404, detail = "no job")
|
||||
return state
|
||||
|
||||
|
||||
|
|
@ -85,7 +92,7 @@ def cancel_job(job_id: str):
|
|||
mgr = get_job_manager()
|
||||
ok = mgr.cancel(job_id)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="job not found")
|
||||
raise HTTPException(status_code = 404, detail = "job not found")
|
||||
return mgr.get_status(job_id)
|
||||
|
||||
|
||||
|
|
@ -94,22 +101,22 @@ def job_analysis(job_id: str):
|
|||
mgr = get_job_manager()
|
||||
analysis = mgr.get_analysis(job_id)
|
||||
if analysis is None:
|
||||
raise HTTPException(status_code=404, detail="analysis not ready")
|
||||
raise HTTPException(status_code = 404, detail = "analysis not ready")
|
||||
return analysis
|
||||
|
||||
|
||||
@router.get("/jobs/{job_id}/dataset")
|
||||
def job_dataset(
|
||||
job_id: str,
|
||||
limit: int = Query(default=20, ge=1, le=500),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
limit: int = Query(default = 20, ge = 1, le = 500),
|
||||
offset: int = Query(default = 0, ge = 0),
|
||||
):
|
||||
mgr = get_job_manager()
|
||||
result = mgr.get_dataset(job_id, limit=limit, offset=offset)
|
||||
result = mgr.get_dataset(job_id, limit = limit, offset = offset)
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail="dataset not ready")
|
||||
raise HTTPException(status_code = 404, detail = "dataset not ready")
|
||||
if "error" in result:
|
||||
raise HTTPException(status_code=422, detail=result["error"])
|
||||
raise HTTPException(status_code = 422, detail = result["error"])
|
||||
return {
|
||||
"dataset": result["dataset"],
|
||||
"total": result["total"],
|
||||
|
|
@ -136,9 +143,9 @@ async def job_events(request: Request, job_id: str):
|
|||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
sub = mgr.subscribe(job_id, after_seq=after_seq)
|
||||
sub = mgr.subscribe(job_id, after_seq = after_seq)
|
||||
if sub is None:
|
||||
raise HTTPException(status_code=404, detail="job not found")
|
||||
raise HTTPException(status_code = 404, detail = "job not found")
|
||||
|
||||
async def gen():
|
||||
try:
|
||||
|
|
@ -148,11 +155,11 @@ async def job_events(request: Request, job_id: str):
|
|||
while True:
|
||||
if await request.is_disconnected():
|
||||
break
|
||||
event = await sub.next_event(timeout_sec=1.0)
|
||||
event = await sub.next_event(timeout_sec = 1.0)
|
||||
if event is None:
|
||||
continue
|
||||
yield sub.format_sse(event)
|
||||
finally:
|
||||
mgr.unsubscribe(sub)
|
||||
|
||||
return StreamingResponse(gen(), media_type="text/event-stream")
|
||||
return StreamingResponse(gen(), media_type = "text/event-stream")
|
||||
|
|
|
|||
|
|
@ -19,16 +19,16 @@ from models.data_recipe import (
|
|||
router = APIRouter()
|
||||
|
||||
|
||||
@router.post("/mcp/tools", response_model=McpToolsListResponse)
|
||||
@router.post("/mcp/tools", response_model = McpToolsListResponse)
|
||||
def list_mcp_tools(payload: McpToolsListRequest) -> McpToolsListResponse:
|
||||
try:
|
||||
from data_designer.engine.mcp import io as mcp_io
|
||||
except ImportError as exc:
|
||||
return McpToolsListResponse(
|
||||
providers=[
|
||||
providers = [
|
||||
McpToolsProviderResult(
|
||||
name="",
|
||||
error=f"MCP dependencies unavailable: {exc}",
|
||||
name = "",
|
||||
error = f"MCP dependencies unavailable: {exc}",
|
||||
)
|
||||
]
|
||||
)
|
||||
|
|
@ -42,29 +42,31 @@ def list_mcp_tools(payload: McpToolsListRequest) -> McpToolsListResponse:
|
|||
if len(built) != 1:
|
||||
providers.append(
|
||||
McpToolsProviderResult(
|
||||
name=provider_name,
|
||||
error="Unsupported MCP provider config.",
|
||||
name = provider_name,
|
||||
error = "Unsupported MCP provider config.",
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
provider = built[0]
|
||||
try:
|
||||
tools = mcp_io.list_tools(provider, timeout_sec=payload.timeout_sec)
|
||||
tool_names = sorted({tool.name for tool in tools if getattr(tool, "name", "")})
|
||||
tools = mcp_io.list_tools(provider, timeout_sec = payload.timeout_sec)
|
||||
tool_names = sorted(
|
||||
{tool.name for tool in tools if getattr(tool, "name", "")}
|
||||
)
|
||||
for tool_name in tool_names:
|
||||
tool_to_providers[tool_name].append(provider.name)
|
||||
providers.append(
|
||||
McpToolsProviderResult(
|
||||
name=provider.name,
|
||||
tools=tool_names,
|
||||
name = provider.name,
|
||||
tools = tool_names,
|
||||
)
|
||||
)
|
||||
except Exception as exc:
|
||||
providers.append(
|
||||
McpToolsProviderResult(
|
||||
name=provider.name or provider_name,
|
||||
error=str(exc).strip() or "Failed to load tools.",
|
||||
name = provider.name or provider_name,
|
||||
error = str(exc).strip() or "Failed to load tools.",
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -75,6 +77,6 @@ def list_mcp_tools(payload: McpToolsListRequest) -> McpToolsListResponse:
|
|||
}
|
||||
|
||||
return McpToolsListResponse(
|
||||
providers=providers,
|
||||
duplicate_tools=duplicate_tools,
|
||||
providers = providers,
|
||||
duplicate_tools = duplicate_tools,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ def _list_hf_data_files(*, dataset_name: str, token: str | None) -> list[str]:
|
|||
return []
|
||||
try:
|
||||
api = HfApi()
|
||||
repo_files = api.list_repo_files(dataset_name, repo_type="dataset", token=token)
|
||||
repo_files = api.list_repo_files(dataset_name, repo_type = "dataset", token = token)
|
||||
return [file for file in repo_files if file.lower().endswith(DATA_EXTS)]
|
||||
except (HfHubHTTPError, OSError, ValueError):
|
||||
return []
|
||||
|
|
@ -86,10 +86,12 @@ def _select_best_file(data_files: list[str], split: str = DEFAULT_SPLIT) -> str
|
|||
return (1, len(path))
|
||||
return (2, len(path))
|
||||
|
||||
return sorted(data_files, key=score)[0]
|
||||
return sorted(data_files, key = score)[0]
|
||||
|
||||
|
||||
def _resolve_seed_hf_path(dataset_name: str, data_files: list[str], split: str = DEFAULT_SPLIT) -> str | None:
|
||||
def _resolve_seed_hf_path(
|
||||
dataset_name: str, data_files: list[str], split: str = DEFAULT_SPLIT
|
||||
) -> str | None:
|
||||
selected = _select_best_file(data_files, split)
|
||||
if not selected:
|
||||
return None
|
||||
|
|
@ -156,36 +158,42 @@ def _decode_base64_payload(content_base64: str) -> bytes:
|
|||
if "," in raw and raw.lower().startswith("data:"):
|
||||
raw = raw.split(",", 1)[1]
|
||||
try:
|
||||
return base64.b64decode(raw, validate=True)
|
||||
return base64.b64decode(raw, validate = True)
|
||||
except binascii.Error as exc:
|
||||
raise HTTPException(status_code=400, detail="invalid base64 payload") from exc
|
||||
raise HTTPException(status_code = 400, detail = "invalid base64 payload") from exc
|
||||
|
||||
|
||||
def _read_preview_rows_from_local_file(path: Path, preview_size: int) -> list[dict[str, Any]]:
|
||||
def _read_preview_rows_from_local_file(
|
||||
path: Path, preview_size: int
|
||||
) -> list[dict[str, Any]]:
|
||||
try:
|
||||
import pandas as pd
|
||||
except ImportError as exc:
|
||||
raise HTTPException(status_code=500, detail=f"seed inspect dependencies unavailable: {exc}") from exc
|
||||
raise HTTPException(
|
||||
status_code = 500, detail = f"seed inspect dependencies unavailable: {exc}"
|
||||
) from exc
|
||||
|
||||
ext = path.suffix.lower()
|
||||
try:
|
||||
if ext == ".csv":
|
||||
df = pd.read_csv(path, nrows=preview_size)
|
||||
df = pd.read_csv(path, nrows = preview_size)
|
||||
elif ext == ".jsonl":
|
||||
df = pd.read_json(path, lines=True).head(preview_size)
|
||||
df = pd.read_json(path, lines = True).head(preview_size)
|
||||
elif ext == ".json":
|
||||
try:
|
||||
df = pd.read_json(path).head(preview_size)
|
||||
except ValueError:
|
||||
df = pd.read_json(path, lines=True).head(preview_size)
|
||||
df = pd.read_json(path, lines = True).head(preview_size)
|
||||
else:
|
||||
raise HTTPException(status_code=422, detail=f"unsupported file type: {ext}")
|
||||
raise HTTPException(status_code = 422, detail = f"unsupported file type: {ext}")
|
||||
except HTTPException:
|
||||
raise
|
||||
except (ValueError, OSError) as exc:
|
||||
raise HTTPException(status_code=422, detail=f"seed inspect failed: {exc}") from exc
|
||||
raise HTTPException(
|
||||
status_code = 422, detail = f"seed inspect failed: {exc}"
|
||||
) from exc
|
||||
|
||||
rows = df.to_dict(orient="records")
|
||||
rows = df.to_dict(orient = "records")
|
||||
return _serialize_preview_rows(rows)
|
||||
|
||||
|
||||
|
|
@ -199,26 +207,33 @@ def _read_preview_rows_from_unstructured_file(
|
|||
size, overlap = resolve_chunking(chunk_size, chunk_overlap)
|
||||
try:
|
||||
rows = build_unstructured_preview_rows(
|
||||
source_path=path,
|
||||
preview_size=preview_size,
|
||||
chunk_size=size,
|
||||
chunk_overlap=overlap,
|
||||
source_path = path,
|
||||
preview_size = preview_size,
|
||||
chunk_size = size,
|
||||
chunk_overlap = overlap,
|
||||
)
|
||||
except (FileNotFoundError, RuntimeError, ValueError, OSError) as exc:
|
||||
raise HTTPException(status_code=422, detail=f"seed inspect failed: {exc}") from exc
|
||||
raise HTTPException(
|
||||
status_code = 422, detail = f"seed inspect failed: {exc}"
|
||||
) from exc
|
||||
return _serialize_preview_rows(rows)
|
||||
|
||||
|
||||
@router.post("/seed/inspect", response_model=SeedInspectResponse)
|
||||
@router.post("/seed/inspect", response_model = SeedInspectResponse)
|
||||
def inspect_seed_dataset(payload: SeedInspectRequest) -> SeedInspectResponse:
|
||||
dataset_name = payload.dataset_name.strip()
|
||||
if not dataset_name or dataset_name.count("/") < 1:
|
||||
raise HTTPException(status_code=400, detail="dataset_name must be a Hugging Face repo id like org/repo")
|
||||
raise HTTPException(
|
||||
status_code = 400,
|
||||
detail = "dataset_name must be a Hugging Face repo id like org/repo",
|
||||
)
|
||||
|
||||
try:
|
||||
from datasets import load_dataset
|
||||
except ImportError as exc:
|
||||
raise HTTPException(status_code=500, detail=f"seed inspect dependencies unavailable: {exc}") from exc
|
||||
raise HTTPException(
|
||||
status_code = 500, detail = f"seed inspect dependencies unavailable: {exc}"
|
||||
) from exc
|
||||
|
||||
split = _normalize_optional_text(payload.split) or DEFAULT_SPLIT
|
||||
subset = _normalize_optional_text(payload.subset)
|
||||
|
|
@ -226,22 +241,22 @@ def inspect_seed_dataset(payload: SeedInspectRequest) -> SeedInspectResponse:
|
|||
preview_size = int(payload.preview_size)
|
||||
|
||||
preview_rows: list[dict[str, Any]] = []
|
||||
data_files = _list_hf_data_files(dataset_name=dataset_name, token=token)
|
||||
data_files = _list_hf_data_files(dataset_name = dataset_name, token = token)
|
||||
|
||||
selected_file = _select_best_file(data_files, split)
|
||||
if selected_file:
|
||||
try:
|
||||
single_file_kwargs = _build_stream_load_kwargs(
|
||||
dataset_name=dataset_name,
|
||||
split=split,
|
||||
subset=subset,
|
||||
token=token,
|
||||
data_file=selected_file,
|
||||
dataset_name = dataset_name,
|
||||
split = split,
|
||||
subset = subset,
|
||||
token = token,
|
||||
data_file = selected_file,
|
||||
)
|
||||
preview_rows = _load_preview_rows(
|
||||
load_dataset_fn=load_dataset,
|
||||
load_kwargs=single_file_kwargs,
|
||||
preview_size=preview_size,
|
||||
load_dataset_fn = load_dataset,
|
||||
load_kwargs = single_file_kwargs,
|
||||
preview_size = preview_size,
|
||||
)
|
||||
except (ValueError, OSError, RuntimeError):
|
||||
preview_rows = []
|
||||
|
|
@ -249,21 +264,25 @@ def inspect_seed_dataset(payload: SeedInspectRequest) -> SeedInspectResponse:
|
|||
if not preview_rows:
|
||||
try:
|
||||
split_kwargs = _build_stream_load_kwargs(
|
||||
dataset_name=dataset_name,
|
||||
split=split,
|
||||
subset=subset,
|
||||
token=token,
|
||||
dataset_name = dataset_name,
|
||||
split = split,
|
||||
subset = subset,
|
||||
token = token,
|
||||
)
|
||||
preview_rows = _load_preview_rows(
|
||||
load_dataset_fn=load_dataset,
|
||||
load_kwargs=split_kwargs,
|
||||
preview_size=preview_size,
|
||||
load_dataset_fn = load_dataset,
|
||||
load_kwargs = split_kwargs,
|
||||
preview_size = preview_size,
|
||||
)
|
||||
except (ValueError, OSError, RuntimeError) as exc:
|
||||
raise HTTPException(status_code=422, detail=f"seed inspect failed: {exc}") from exc
|
||||
raise HTTPException(
|
||||
status_code = 422, detail = f"seed inspect failed: {exc}"
|
||||
) from exc
|
||||
|
||||
if not preview_rows:
|
||||
raise HTTPException(status_code=422, detail="dataset appears empty or unreadable")
|
||||
raise HTTPException(
|
||||
status_code = 422, detail = "dataset appears empty or unreadable"
|
||||
)
|
||||
preview_rows = _serialize_preview_rows(preview_rows)
|
||||
columns = _extract_columns(preview_rows)
|
||||
|
||||
|
|
@ -272,19 +291,21 @@ def inspect_seed_dataset(payload: SeedInspectRequest) -> SeedInspectResponse:
|
|||
else:
|
||||
resolved_path = _resolve_seed_hf_path(dataset_name, data_files, split)
|
||||
if not resolved_path:
|
||||
raise HTTPException(status_code=422, detail="unable to resolve seed dataset path")
|
||||
raise HTTPException(
|
||||
status_code = 422, detail = "unable to resolve seed dataset path"
|
||||
)
|
||||
|
||||
return SeedInspectResponse(
|
||||
dataset_name=dataset_name,
|
||||
resolved_path=resolved_path,
|
||||
columns=columns,
|
||||
preview_rows=preview_rows,
|
||||
split=split,
|
||||
subset=subset,
|
||||
dataset_name = dataset_name,
|
||||
resolved_path = resolved_path,
|
||||
columns = columns,
|
||||
preview_rows = preview_rows,
|
||||
split = split,
|
||||
subset = subset,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/seed/inspect-upload", response_model=SeedInspectResponse)
|
||||
@router.post("/seed/inspect-upload", response_model = SeedInspectResponse)
|
||||
def inspect_seed_upload(payload: SeedInspectUploadRequest) -> SeedInspectResponse:
|
||||
seed_source_type = _normalize_optional_text(payload.seed_source_type) or "local"
|
||||
filename = _sanitize_filename(payload.filename)
|
||||
|
|
@ -292,18 +313,24 @@ def inspect_seed_upload(payload: SeedInspectUploadRequest) -> SeedInspectRespons
|
|||
if seed_source_type == "unstructured":
|
||||
if ext not in UNSTRUCTURED_UPLOAD_EXTS:
|
||||
allowed = ", ".join(sorted(UNSTRUCTURED_UPLOAD_EXTS))
|
||||
raise HTTPException(status_code=400, detail=f"unsupported file type: {ext}. allowed: {allowed}")
|
||||
raise HTTPException(
|
||||
status_code = 400,
|
||||
detail = f"unsupported file type: {ext}. allowed: {allowed}",
|
||||
)
|
||||
else:
|
||||
if ext not in LOCAL_UPLOAD_EXTS:
|
||||
allowed = ", ".join(sorted(LOCAL_UPLOAD_EXTS))
|
||||
raise HTTPException(status_code=400, detail=f"unsupported file type: {ext}. allowed: {allowed}")
|
||||
raise HTTPException(
|
||||
status_code = 400,
|
||||
detail = f"unsupported file type: {ext}. allowed: {allowed}",
|
||||
)
|
||||
|
||||
file_bytes = _decode_base64_payload(payload.content_base64)
|
||||
if not file_bytes:
|
||||
raise HTTPException(status_code=400, detail="empty upload payload")
|
||||
raise HTTPException(status_code = 400, detail = "empty upload payload")
|
||||
max_size_bytes = 50 * 1024 * 1024
|
||||
if len(file_bytes) > max_size_bytes:
|
||||
raise HTTPException(status_code=413, detail="file too large (max 50MB)")
|
||||
raise HTTPException(status_code = 413, detail = "file too large (max 50MB)")
|
||||
|
||||
ensure_dir(SEED_UPLOAD_DIR)
|
||||
stored_name = f"{uuid4().hex}_{filename}"
|
||||
|
|
@ -312,10 +339,10 @@ def inspect_seed_upload(payload: SeedInspectUploadRequest) -> SeedInspectRespons
|
|||
|
||||
if seed_source_type == "unstructured":
|
||||
preview_rows = _read_preview_rows_from_unstructured_file(
|
||||
path=stored_path,
|
||||
preview_size=int(payload.preview_size),
|
||||
chunk_size=payload.unstructured_chunk_size,
|
||||
chunk_overlap=payload.unstructured_chunk_overlap,
|
||||
path = stored_path,
|
||||
preview_size = int(payload.preview_size),
|
||||
chunk_size = payload.unstructured_chunk_size,
|
||||
chunk_overlap = payload.unstructured_chunk_overlap,
|
||||
)
|
||||
else:
|
||||
preview_rows = _read_preview_rows_from_local_file(
|
||||
|
|
@ -323,14 +350,16 @@ def inspect_seed_upload(payload: SeedInspectUploadRequest) -> SeedInspectRespons
|
|||
int(payload.preview_size),
|
||||
)
|
||||
if not preview_rows:
|
||||
raise HTTPException(status_code=422, detail="dataset appears empty or unreadable")
|
||||
raise HTTPException(
|
||||
status_code = 422, detail = "dataset appears empty or unreadable"
|
||||
)
|
||||
columns = _extract_columns(preview_rows)
|
||||
|
||||
return SeedInspectResponse(
|
||||
dataset_name=filename,
|
||||
resolved_path=str(stored_path),
|
||||
columns=columns,
|
||||
preview_rows=preview_rows,
|
||||
split=None,
|
||||
subset=None,
|
||||
dataset_name = filename,
|
||||
resolved_path = str(stored_path),
|
||||
columns = columns,
|
||||
preview_rows = preview_rows,
|
||||
split = None,
|
||||
subset = None,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -44,9 +44,9 @@ def _collect_validation_errors(recipe: dict[str, Any]) -> list[ValidateError]:
|
|||
_resolve_and_add_seed_columns(config, resource_provider.seed_reader)
|
||||
_add_internal_row_id_column_if_needed(config)
|
||||
violations = validate_data_designer_config(
|
||||
columns=config.columns,
|
||||
processor_configs=config.processors or [],
|
||||
allowed_references=_get_allowed_references(config),
|
||||
columns = config.columns,
|
||||
processor_configs = config.processors or [],
|
||||
allowed_references = _get_allowed_references(config),
|
||||
)
|
||||
except (TypeError, ValueError, AttributeError):
|
||||
return []
|
||||
|
|
@ -60,34 +60,34 @@ def _collect_validation_errors(recipe: dict[str, Any]) -> list[ValidateError]:
|
|||
message = str(violation.message).strip() or "Validation failed."
|
||||
errors.append(
|
||||
ValidateError(
|
||||
message=message,
|
||||
path=path,
|
||||
code=code,
|
||||
message = message,
|
||||
path = path,
|
||||
code = code,
|
||||
)
|
||||
)
|
||||
return errors
|
||||
|
||||
|
||||
@router.post("/validate", response_model=ValidateResponse)
|
||||
@router.post("/validate", response_model = ValidateResponse)
|
||||
def validate(payload: RecipePayload) -> ValidateResponse:
|
||||
recipe = payload.recipe
|
||||
if not recipe.get("columns"):
|
||||
return ValidateResponse(
|
||||
valid=False,
|
||||
errors=[ValidateError(message="Recipe must include columns.")],
|
||||
valid = False,
|
||||
errors = [ValidateError(message = "Recipe must include columns.")],
|
||||
)
|
||||
|
||||
try:
|
||||
validate_recipe(recipe)
|
||||
except RuntimeError as exc:
|
||||
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
||||
raise HTTPException(status_code = 503, detail = str(exc)) from exc
|
||||
except Exception as exc:
|
||||
detail = str(exc).strip() or "Validation failed."
|
||||
parsed_errors = _collect_validation_errors(recipe)
|
||||
return ValidateResponse(
|
||||
valid=False,
|
||||
errors=parsed_errors or [ValidateError(message=detail)],
|
||||
raw_detail=detail,
|
||||
valid = False,
|
||||
errors = parsed_errors or [ValidateError(message = detail)],
|
||||
raw_detail = detail,
|
||||
)
|
||||
|
||||
return ValidateResponse(valid=True)
|
||||
return ValidateResponse(valid = True)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"""
|
||||
Datasets API routes
|
||||
"""
|
||||
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
|
|
@ -27,8 +28,6 @@ router = APIRouter()
|
|||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
|
||||
|
||||
from models.datasets import (
|
||||
AiAssistMappingRequest,
|
||||
AiAssistMappingResponse,
|
||||
|
|
@ -53,9 +52,10 @@ def _serialize_preview_value(value):
|
|||
|
||||
try:
|
||||
from PIL.Image import Image as PILImage
|
||||
|
||||
if isinstance(value, PILImage):
|
||||
buffer = io.BytesIO()
|
||||
value.convert("RGB").save(buffer, format="JPEG", quality=85)
|
||||
value.convert("RGB").save(buffer, format = "JPEG", quality = 85)
|
||||
return {
|
||||
"type": "image",
|
||||
"mime": "image/jpeg",
|
||||
|
|
@ -88,10 +88,10 @@ def _serialize_preview_rows(rows):
|
|||
# Tabular formats are preferred over archives for Tier 1 preview because
|
||||
# archives (e.g. images.zip) may be loaded as ImageFolder datasets with
|
||||
# synthetic columns (image/label) that don't match the real dataset schema.
|
||||
_TABULAR_EXTS = ('.parquet', '.json', '.jsonl', '.csv', '.tsv', '.arrow')
|
||||
_ARCHIVE_EXTS = ('.tar', '.tar.gz', '.tgz', '.gz', '.zst', '.zip', '.txt')
|
||||
_TABULAR_EXTS = (".parquet", ".json", ".jsonl", ".csv", ".tsv", ".arrow")
|
||||
_ARCHIVE_EXTS = (".tar", ".tar.gz", ".tgz", ".gz", ".zst", ".zip", ".txt")
|
||||
DATA_EXTS = _TABULAR_EXTS + _ARCHIVE_EXTS
|
||||
LOCAL_FILE_EXTS = ('.json', '.jsonl', '.csv', '.parquet')
|
||||
LOCAL_FILE_EXTS = (".json", ".jsonl", ".csv", ".parquet")
|
||||
LOCAL_UPLOAD_EXTS = {".csv", ".json", ".jsonl", ".parquet"}
|
||||
LOCAL_DATASETS_ROOT = recipe_datasets_root()
|
||||
DATASET_UPLOAD_DIR = dataset_uploads_root()
|
||||
|
|
@ -99,7 +99,7 @@ DATASET_UPLOAD_DIR = dataset_uploads_root()
|
|||
|
||||
def _safe_read_metadata(path: Path) -> dict | None:
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
payload = json.loads(path.read_text(encoding = "utf-8"))
|
||||
except (OSError, ValueError, TypeError):
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
|
|
@ -200,30 +200,36 @@ def _build_local_dataset_items() -> list[LocalDatasetItem]:
|
|||
|
||||
items.append(
|
||||
LocalDatasetItem(
|
||||
id=entry.name,
|
||||
label=entry.name,
|
||||
path=str(parquet_dir.resolve()),
|
||||
rows=rows,
|
||||
updated_at=updated_at,
|
||||
metadata=metadata_summary,
|
||||
id = entry.name,
|
||||
label = entry.name,
|
||||
path = str(parquet_dir.resolve()),
|
||||
rows = rows,
|
||||
updated_at = updated_at,
|
||||
metadata = metadata_summary,
|
||||
)
|
||||
)
|
||||
|
||||
items.sort(key=lambda item: item.updated_at or 0, reverse=True)
|
||||
items.sort(key = lambda item: item.updated_at or 0, reverse = True)
|
||||
return items
|
||||
|
||||
|
||||
def _load_local_preview_slice(*, dataset_path: Path, train_split: str, preview_size: int):
|
||||
def _load_local_preview_slice(
|
||||
*, dataset_path: Path, train_split: str, preview_size: int
|
||||
):
|
||||
from datasets import load_dataset
|
||||
|
||||
if dataset_path.is_dir():
|
||||
parquet_dir = dataset_path / "parquet-files" if (dataset_path / "parquet-files").exists() else dataset_path
|
||||
parquet_dir = (
|
||||
dataset_path / "parquet-files"
|
||||
if (dataset_path / "parquet-files").exists()
|
||||
else dataset_path
|
||||
)
|
||||
parquet_files = sorted(parquet_dir.glob("*.parquet"))
|
||||
if parquet_files:
|
||||
dataset = load_dataset(
|
||||
"parquet",
|
||||
data_files=[str(path) for path in parquet_files],
|
||||
split=train_split,
|
||||
data_files = [str(path) for path in parquet_files],
|
||||
split = train_split,
|
||||
)
|
||||
total_rows = len(dataset)
|
||||
preview_slice = dataset.select(range(min(preview_size, total_rows)))
|
||||
|
|
@ -234,21 +240,22 @@ def _load_local_preview_slice(*, dataset_path: Path, train_split: str, preview_s
|
|||
candidate_files.extend(sorted(dataset_path.glob(f"*{ext}")))
|
||||
if not candidate_files:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Unsupported local dataset directory (expected parquet/json/jsonl/csv files)",
|
||||
status_code = 400,
|
||||
detail = "Unsupported local dataset directory (expected parquet/json/jsonl/csv files)",
|
||||
)
|
||||
dataset_path = candidate_files[0]
|
||||
|
||||
if dataset_path.suffix in ['.json', '.jsonl']:
|
||||
dataset = load_dataset('json', data_files=str(dataset_path), split=train_split)
|
||||
elif dataset_path.suffix == '.csv':
|
||||
dataset = load_dataset('csv', data_files=str(dataset_path), split=train_split)
|
||||
elif dataset_path.suffix == '.parquet':
|
||||
dataset = load_dataset('parquet', data_files=str(dataset_path), split=train_split)
|
||||
if dataset_path.suffix in [".json", ".jsonl"]:
|
||||
dataset = load_dataset("json", data_files = str(dataset_path), split = train_split)
|
||||
elif dataset_path.suffix == ".csv":
|
||||
dataset = load_dataset("csv", data_files = str(dataset_path), split = train_split)
|
||||
elif dataset_path.suffix == ".parquet":
|
||||
dataset = load_dataset(
|
||||
"parquet", data_files = str(dataset_path), split = train_split
|
||||
)
|
||||
else:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unsupported file format: {dataset_path.suffix}"
|
||||
status_code = 400, detail = f"Unsupported file format: {dataset_path.suffix}"
|
||||
)
|
||||
|
||||
total_rows = len(dataset)
|
||||
|
|
@ -263,7 +270,7 @@ def _sanitize_filename(filename: str) -> str:
|
|||
return name
|
||||
|
||||
|
||||
@router.post("/upload", response_model=UploadDatasetResponse)
|
||||
@router.post("/upload", response_model = UploadDatasetResponse)
|
||||
async def upload_dataset(
|
||||
file: UploadFile,
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
|
|
@ -273,8 +280,8 @@ async def upload_dataset(
|
|||
if ext not in LOCAL_UPLOAD_EXTS:
|
||||
allowed = ", ".join(sorted(LOCAL_UPLOAD_EXTS))
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unsupported file type: {ext}. Allowed: {allowed}",
|
||||
status_code = 400,
|
||||
detail = f"Unsupported file type: {ext}. Allowed: {allowed}",
|
||||
)
|
||||
|
||||
max_size_bytes = 512 * 1024 * 1024
|
||||
|
|
@ -289,25 +296,27 @@ async def upload_dataset(
|
|||
while chunk := await file.read(1024 * 1024):
|
||||
size += len(chunk)
|
||||
if size > max_size_bytes:
|
||||
stored_path.unlink(missing_ok=True)
|
||||
raise HTTPException(status_code=413, detail="File too large (max 512MB)")
|
||||
stored_path.unlink(missing_ok = True)
|
||||
raise HTTPException(
|
||||
status_code = 413, detail = "File too large (max 512MB)"
|
||||
)
|
||||
f.write(chunk)
|
||||
|
||||
if size == 0:
|
||||
stored_path.unlink(missing_ok=True)
|
||||
raise HTTPException(status_code=400, detail="Empty upload payload")
|
||||
stored_path.unlink(missing_ok = True)
|
||||
raise HTTPException(status_code = 400, detail = "Empty upload payload")
|
||||
|
||||
return UploadDatasetResponse(filename=filename, stored_path=str(stored_path))
|
||||
return UploadDatasetResponse(filename = filename, stored_path = str(stored_path))
|
||||
|
||||
|
||||
@router.get("/local", response_model=LocalDatasetsResponse)
|
||||
@router.get("/local", response_model = LocalDatasetsResponse)
|
||||
def list_local_datasets(
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
) -> LocalDatasetsResponse:
|
||||
return LocalDatasetsResponse(datasets=_build_local_dataset_items())
|
||||
return LocalDatasetsResponse(datasets = _build_local_dataset_items())
|
||||
|
||||
|
||||
@router.post("/check-format", response_model=CheckFormatResponse)
|
||||
@router.post("/check-format", response_model = CheckFormatResponse)
|
||||
def check_format(
|
||||
request: CheckFormatRequest,
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
|
|
@ -341,9 +350,9 @@ def check_format(
|
|||
# ── Local file ──────────────────────────────────────────
|
||||
train_split = request.train_split or "train"
|
||||
preview_slice, total_rows = _load_local_preview_slice(
|
||||
dataset_path=dataset_path,
|
||||
train_split=train_split,
|
||||
preview_size=PREVIEW_SIZE,
|
||||
dataset_path = dataset_path,
|
||||
train_split = train_split,
|
||||
preview_size = PREVIEW_SIZE,
|
||||
)
|
||||
else:
|
||||
# ── HuggingFace dataset ─────────────────────────────────
|
||||
|
|
@ -352,23 +361,32 @@ def check_format(
|
|||
|
||||
try:
|
||||
from huggingface_hub import HfApi
|
||||
|
||||
api = HfApi()
|
||||
repo_files = api.list_repo_files(
|
||||
request.dataset_name,
|
||||
repo_type="dataset",
|
||||
token=request.hf_token or None,
|
||||
repo_type = "dataset",
|
||||
token = request.hf_token or None,
|
||||
)
|
||||
data_files = [f for f in repo_files if any(f.endswith(ext) for ext in DATA_EXTS)]
|
||||
data_files = [
|
||||
f for f in repo_files if any(f.endswith(ext) for ext in DATA_EXTS)
|
||||
]
|
||||
|
||||
# Prefer tabular formats over archives (e.g. images.zip → ImageFolder
|
||||
# with synthetic image/label columns that don't match the real schema).
|
||||
tabular_files = [f for f in data_files if any(f.endswith(ext) for ext in _TABULAR_EXTS)]
|
||||
tabular_files = [
|
||||
f
|
||||
for f in data_files
|
||||
if any(f.endswith(ext) for ext in _TABULAR_EXTS)
|
||||
]
|
||||
candidates = tabular_files or data_files
|
||||
|
||||
# When a subset is specified, narrow to files whose name matches
|
||||
# (e.g. subset="testmini" → prefer "testmini.parquet").
|
||||
if request.subset and candidates:
|
||||
subset_matches = [f for f in candidates if request.subset in Path(f).stem]
|
||||
subset_matches = [
|
||||
f for f in candidates if request.subset in Path(f).stem
|
||||
]
|
||||
if subset_matches:
|
||||
candidates = subset_matches
|
||||
|
||||
|
|
@ -394,7 +412,11 @@ def check_format(
|
|||
if preview_slice is None:
|
||||
# Tier 2: full streaming (resolves all files — slow for large repos)
|
||||
logger.info("Tier 2: falling back to full streaming load_dataset")
|
||||
load_kwargs = {"path": request.dataset_name, "split": request.train_split, "streaming": True}
|
||||
load_kwargs = {
|
||||
"path": request.dataset_name,
|
||||
"split": request.train_split,
|
||||
"streaming": True,
|
||||
}
|
||||
if request.subset:
|
||||
load_kwargs["name"] = request.subset
|
||||
if request.hf_token:
|
||||
|
|
@ -405,17 +427,19 @@ def check_format(
|
|||
rows = list(islice(streamed_ds, PREVIEW_SIZE))
|
||||
if not rows:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Dataset appears to be empty or could not be streamed"
|
||||
status_code = 400,
|
||||
detail = "Dataset appears to be empty or could not be streamed",
|
||||
)
|
||||
|
||||
preview_slice = Dataset.from_list(rows)
|
||||
total_rows = None
|
||||
|
||||
# Run lightweight format check on the preview slice
|
||||
result = check_dataset_format(preview_slice, is_vlm=request.is_vlm)
|
||||
result = check_dataset_format(preview_slice, is_vlm = request.is_vlm)
|
||||
|
||||
logger.info(f"Format check result: requires_mapping={result['requires_manual_mapping']}, format={result['detected_format']}, is_image={result.get('is_image', False)}")
|
||||
logger.info(
|
||||
f"Format check result: requires_mapping={result['requires_manual_mapping']}, format={result['detected_format']}, is_image={result.get('is_image', False)}"
|
||||
)
|
||||
|
||||
# Generate preview samples
|
||||
preview_samples = None
|
||||
|
|
@ -428,13 +452,15 @@ def check_format(
|
|||
try:
|
||||
format_result = format_dataset(
|
||||
preview_slice,
|
||||
format_type="auto",
|
||||
num_proc=1, # Only 10 preview rows — no need for multiprocessing
|
||||
format_type = "auto",
|
||||
num_proc = 1, # Only 10 preview rows — no need for multiprocessing
|
||||
)
|
||||
processed = format_result["dataset"]
|
||||
preview_samples = _serialize_preview_rows(processed)
|
||||
except Exception as e:
|
||||
logger.warning(f"Processed preview generation failed (non-fatal): {e}")
|
||||
logger.warning(
|
||||
f"Processed preview generation failed (non-fatal): {e}"
|
||||
)
|
||||
preview_samples = _serialize_preview_rows(preview_slice)
|
||||
else:
|
||||
preview_samples = _serialize_preview_rows(preview_slice)
|
||||
|
|
@ -445,7 +471,9 @@ def check_format(
|
|||
if image_col and image_col in (result.get("columns") or []):
|
||||
try:
|
||||
sample_val = preview_slice[0][image_col]
|
||||
if isinstance(sample_val, str) and sample_val.startswith(("http://", "https://")):
|
||||
if isinstance(sample_val, str) and sample_val.startswith(
|
||||
("http://", "https://")
|
||||
):
|
||||
url_warning = (
|
||||
"This dataset contains image URLs instead of embedded images. "
|
||||
"Images will be downloaded during training, which may be slow for large datasets."
|
||||
|
|
@ -456,33 +484,32 @@ def check_format(
|
|||
pass
|
||||
|
||||
return CheckFormatResponse(
|
||||
requires_manual_mapping=result["requires_manual_mapping"],
|
||||
detected_format=result["detected_format"],
|
||||
columns=result["columns"],
|
||||
is_image=result.get("is_image", False),
|
||||
is_audio=result.get("is_audio", False),
|
||||
multimodal_columns=result.get("multimodal_columns"),
|
||||
suggested_mapping=result.get("suggested_mapping"),
|
||||
detected_image_column=result.get("detected_image_column"),
|
||||
detected_audio_column=result.get("detected_audio_column"),
|
||||
detected_text_column=result.get("detected_text_column"),
|
||||
detected_speaker_column=result.get("detected_speaker_column"),
|
||||
preview_samples=preview_samples,
|
||||
total_rows=total_rows,
|
||||
warning=warning,
|
||||
requires_manual_mapping = result["requires_manual_mapping"],
|
||||
detected_format = result["detected_format"],
|
||||
columns = result["columns"],
|
||||
is_image = result.get("is_image", False),
|
||||
is_audio = result.get("is_audio", False),
|
||||
multimodal_columns = result.get("multimodal_columns"),
|
||||
suggested_mapping = result.get("suggested_mapping"),
|
||||
detected_image_column = result.get("detected_image_column"),
|
||||
detected_audio_column = result.get("detected_audio_column"),
|
||||
detected_text_column = result.get("detected_text_column"),
|
||||
detected_speaker_column = result.get("detected_speaker_column"),
|
||||
preview_samples = preview_samples,
|
||||
total_rows = total_rows,
|
||||
warning = warning,
|
||||
)
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking dataset format: {e}", exc_info=True)
|
||||
logger.error(f"Error checking dataset format: {e}", exc_info = True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to check dataset format: {str(e)}"
|
||||
status_code = 500, detail = f"Failed to check dataset format: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/ai-assist-mapping", response_model=AiAssistMappingResponse)
|
||||
@router.post("/ai-assist-mapping", response_model = AiAssistMappingResponse)
|
||||
def ai_assist_mapping(
|
||||
request: AiAssistMappingRequest,
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
|
|
@ -507,35 +534,32 @@ def ai_assist_mapping(
|
|||
]
|
||||
|
||||
result = llm_conversion_advisor(
|
||||
column_names=request.columns,
|
||||
samples=truncated,
|
||||
dataset_name=request.dataset_name,
|
||||
hf_token=request.hf_token,
|
||||
model_name=request.model_name,
|
||||
model_type=request.model_type,
|
||||
column_names = request.columns,
|
||||
samples = truncated,
|
||||
dataset_name = request.dataset_name,
|
||||
hf_token = request.hf_token,
|
||||
model_name = request.model_name,
|
||||
model_type = request.model_type,
|
||||
)
|
||||
|
||||
if result and result.get("success"):
|
||||
return AiAssistMappingResponse(
|
||||
success=True,
|
||||
suggested_mapping=result.get("suggested_mapping"),
|
||||
system_prompt=result.get("system_prompt"),
|
||||
user_template=result.get("user_template"),
|
||||
assistant_template=result.get("assistant_template"),
|
||||
label_mapping=result.get("label_mapping"),
|
||||
dataset_type=result.get("dataset_type"),
|
||||
is_conversational=result.get("is_conversational"),
|
||||
user_notification=result.get("user_notification"),
|
||||
success = True,
|
||||
suggested_mapping = result.get("suggested_mapping"),
|
||||
system_prompt = result.get("system_prompt"),
|
||||
user_template = result.get("user_template"),
|
||||
assistant_template = result.get("assistant_template"),
|
||||
label_mapping = result.get("label_mapping"),
|
||||
dataset_type = result.get("dataset_type"),
|
||||
is_conversational = result.get("is_conversational"),
|
||||
user_notification = result.get("user_notification"),
|
||||
)
|
||||
|
||||
return AiAssistMappingResponse(
|
||||
success=False,
|
||||
warning="AI could not determine column roles. Please assign them manually.",
|
||||
success = False,
|
||||
warning = "AI could not determine column roles. Please assign them manually.",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"AI assist mapping failed: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"AI assist failed: {str(e)}"
|
||||
)
|
||||
logger.error(f"AI assist mapping failed: {e}", exc_info = True)
|
||||
raise HTTPException(status_code = 500, detail = f"AI assist failed: {str(e)}")
|
||||
|
|
|
|||
|
|
@ -43,11 +43,7 @@ router = APIRouter()
|
|||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@router.post("/load-checkpoint", response_model=ExportOperationResponse)
|
||||
@router.post("/load-checkpoint", response_model = ExportOperationResponse)
|
||||
async def load_checkpoint(
|
||||
request: LoadCheckpointRequest,
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
|
|
@ -65,6 +61,7 @@ async def load_checkpoint(
|
|||
# before loading the export checkpoint (they'd compete for VRAM).
|
||||
try:
|
||||
from core.inference import get_inference_backend
|
||||
|
||||
inf = get_inference_backend()
|
||||
if inf.active_model_name:
|
||||
logger.info(
|
||||
|
|
@ -79,6 +76,7 @@ async def load_checkpoint(
|
|||
|
||||
try:
|
||||
from core.training import get_training_backend
|
||||
|
||||
trn = get_training_backend()
|
||||
if trn.is_training_active():
|
||||
logger.info("Stopping active training to free GPU memory for export")
|
||||
|
|
@ -88,35 +86,39 @@ async def load_checkpoint(
|
|||
for _ in range(60): # up to 30s
|
||||
if not trn.is_training_active():
|
||||
break
|
||||
import time; time.sleep(0.5)
|
||||
import time
|
||||
|
||||
time.sleep(0.5)
|
||||
else:
|
||||
logger.warning("Training subprocess did not exit within 30s, proceeding anyway")
|
||||
logger.warning(
|
||||
"Training subprocess did not exit within 30s, proceeding anyway"
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Could not stop training: %s", e)
|
||||
|
||||
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,
|
||||
trust_remote_code=request.trust_remote_code,
|
||||
checkpoint_path = request.checkpoint_path,
|
||||
max_seq_length = request.max_seq_length,
|
||||
load_in_4bit = request.load_in_4bit,
|
||||
trust_remote_code = request.trust_remote_code,
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail=message)
|
||||
raise HTTPException(status_code = 400, detail = message)
|
||||
|
||||
return ExportOperationResponse(success=True, message=message)
|
||||
return ExportOperationResponse(success = True, message = message)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading checkpoint: {e}", exc_info=True)
|
||||
logger.error(f"Error loading checkpoint: {e}", exc_info = True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to load checkpoint: {str(e)}",
|
||||
status_code = 500,
|
||||
detail = f"Failed to load checkpoint: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/cleanup", response_model=ExportOperationResponse)
|
||||
@router.post("/cleanup", response_model = ExportOperationResponse)
|
||||
async def cleanup_export_memory(
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
):
|
||||
|
|
@ -131,25 +133,25 @@ async def cleanup_export_memory(
|
|||
|
||||
if not success:
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Memory cleanup failed. See server logs for details.",
|
||||
status_code = 500,
|
||||
detail = "Memory cleanup failed. See server logs for details.",
|
||||
)
|
||||
|
||||
return ExportOperationResponse(
|
||||
success=True,
|
||||
message="Memory cleanup completed successfully",
|
||||
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)
|
||||
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)}",
|
||||
status_code = 500,
|
||||
detail = f"Failed to cleanup export memory: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/status", response_model=ExportStatusResponse)
|
||||
@router.get("/status", response_model = ExportStatusResponse)
|
||||
async def get_export_status(
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
):
|
||||
|
|
@ -159,19 +161,19 @@ async def get_export_status(
|
|||
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)),
|
||||
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)
|
||||
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)}",
|
||||
status_code = 500,
|
||||
detail = f"Failed to get export status: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/export/merged", response_model=ExportOperationResponse)
|
||||
@router.post("/export/merged", response_model = ExportOperationResponse)
|
||||
async def export_merged_model(
|
||||
request: ExportMergedModelRequest,
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
|
|
@ -184,29 +186,29 @@ async def 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,
|
||||
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)
|
||||
raise HTTPException(status_code = 400, detail = message)
|
||||
|
||||
return ExportOperationResponse(success=True, message=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)
|
||||
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)}",
|
||||
status_code = 500,
|
||||
detail = f"Failed to export merged model: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/export/base", response_model=ExportOperationResponse)
|
||||
@router.post("/export/base", response_model = ExportOperationResponse)
|
||||
async def export_base_model(
|
||||
request: ExportBaseModelRequest,
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
|
|
@ -219,29 +221,29 @@ async def 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,
|
||||
base_model_id=request.base_model_id,
|
||||
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,
|
||||
base_model_id = request.base_model_id,
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise HTTPException(status_code=400, detail=message)
|
||||
raise HTTPException(status_code = 400, detail = message)
|
||||
|
||||
return ExportOperationResponse(success=True, message=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)
|
||||
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)}",
|
||||
status_code = 500,
|
||||
detail = f"Failed to export base model: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/export/gguf", response_model=ExportOperationResponse)
|
||||
@router.post("/export/gguf", response_model = ExportOperationResponse)
|
||||
async def export_gguf(
|
||||
request: ExportGGUFRequest,
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
|
|
@ -254,28 +256,28 @@ async def 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,
|
||||
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)
|
||||
raise HTTPException(status_code = 400, detail = message)
|
||||
|
||||
return ExportOperationResponse(success=True, message=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)
|
||||
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)}",
|
||||
status_code = 500,
|
||||
detail = f"Failed to export GGUF model: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/export/lora", response_model=ExportOperationResponse)
|
||||
@router.post("/export/lora", response_model = ExportOperationResponse)
|
||||
async def export_lora_adapter(
|
||||
request: ExportLoRAAdapterRequest,
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
|
|
@ -288,24 +290,22 @@ async def 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,
|
||||
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)
|
||||
raise HTTPException(status_code = 400, detail = message)
|
||||
|
||||
return ExportOperationResponse(success=True, message=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)
|
||||
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)}",
|
||||
status_code = 500,
|
||||
detail = f"Failed to export LoRA adapter: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -4,6 +4,8 @@
|
|||
"""
|
||||
Model Management API routes
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
|
|
@ -31,9 +33,18 @@ try:
|
|||
list_gguf_variants,
|
||||
ModelConfig,
|
||||
)
|
||||
from utils.models.model_config import _pick_best_gguf, _extract_quant_label, is_audio_input_type
|
||||
from utils.models.model_config import (
|
||||
_pick_best_gguf,
|
||||
_extract_quant_label,
|
||||
is_audio_input_type,
|
||||
)
|
||||
from core.inference import get_inference_backend
|
||||
from utils.paths import outputs_root, exports_root, resolve_output_dir, resolve_export_dir
|
||||
from utils.paths import (
|
||||
outputs_root,
|
||||
exports_root,
|
||||
resolve_output_dir,
|
||||
resolve_export_dir,
|
||||
)
|
||||
except ImportError:
|
||||
# Fallback: try to import from parent directory
|
||||
parent_backend = backend_path.parent / "backend"
|
||||
|
|
@ -50,9 +61,18 @@ except ImportError:
|
|||
list_gguf_variants,
|
||||
ModelConfig,
|
||||
)
|
||||
from utils.models.model_config import _pick_best_gguf, _extract_quant_label, is_audio_input_type
|
||||
from utils.models.model_config import (
|
||||
_pick_best_gguf,
|
||||
_extract_quant_label,
|
||||
is_audio_input_type,
|
||||
)
|
||||
from core.inference import get_inference_backend
|
||||
from utils.paths import outputs_root, exports_root, resolve_output_dir, resolve_export_dir
|
||||
from utils.paths import (
|
||||
outputs_root,
|
||||
exports_root,
|
||||
resolve_output_dir,
|
||||
resolve_export_dir,
|
||||
)
|
||||
|
||||
from models import (
|
||||
CheckpointInfo,
|
||||
|
|
@ -66,13 +86,19 @@ from models import (
|
|||
ModelListResponse,
|
||||
)
|
||||
from models.models import GgufVariantDetail, GgufVariantsResponse, ModelType
|
||||
from models.responses import LoRABaseModelResponse, VisionCheckResponse, EmbeddingCheckResponse
|
||||
from models.responses import (
|
||||
LoRABaseModelResponse,
|
||||
VisionCheckResponse,
|
||||
EmbeddingCheckResponse,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
def derive_model_type(is_vision: bool, audio_type: Optional[str], is_embedding: bool = False) -> ModelType:
|
||||
def derive_model_type(
|
||||
is_vision: bool, audio_type: Optional[str], is_embedding: bool = False
|
||||
) -> ModelType:
|
||||
"""Collapse individual capability flags into a single model modality string."""
|
||||
if is_embedding:
|
||||
return "embeddings"
|
||||
|
|
@ -83,12 +109,11 @@ def derive_model_type(is_vision: bool, audio_type: Optional[str], is_embedding:
|
|||
return "text"
|
||||
|
||||
|
||||
|
||||
|
||||
def _resolve_hf_cache_dir() -> Path:
|
||||
"""Resolve local HF cache root used by hub downloads."""
|
||||
try:
|
||||
from huggingface_hub.constants import HF_HUB_CACHE
|
||||
|
||||
return Path(HF_HUB_CACHE)
|
||||
except Exception:
|
||||
return Path.home() / ".cache" / "huggingface" / "hub"
|
||||
|
|
@ -117,11 +142,11 @@ def _scan_models_dir(models_dir: Path) -> List[LocalModelInfo]:
|
|||
updated_at = None
|
||||
found.append(
|
||||
LocalModelInfo(
|
||||
id=str(child),
|
||||
display_name=child.name,
|
||||
path=str(child),
|
||||
source="models_dir",
|
||||
updated_at=updated_at,
|
||||
id = str(child),
|
||||
display_name = child.name,
|
||||
path = str(child),
|
||||
source = "models_dir",
|
||||
updated_at = updated_at,
|
||||
),
|
||||
)
|
||||
# Also scan for standalone .gguf files directly in the models directory
|
||||
|
|
@ -133,11 +158,11 @@ def _scan_models_dir(models_dir: Path) -> List[LocalModelInfo]:
|
|||
updated_at = None
|
||||
found.append(
|
||||
LocalModelInfo(
|
||||
id=str(gguf_file),
|
||||
display_name=gguf_file.stem,
|
||||
path=str(gguf_file),
|
||||
source="models_dir",
|
||||
updated_at=updated_at,
|
||||
id = str(gguf_file),
|
||||
display_name = gguf_file.stem,
|
||||
path = str(gguf_file),
|
||||
source = "models_dir",
|
||||
updated_at = updated_at,
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -153,7 +178,7 @@ def _scan_hf_cache(cache_dir: Path) -> List[LocalModelInfo]:
|
|||
if not repo_dir.is_dir():
|
||||
continue
|
||||
|
||||
repo_name = repo_dir.name[len("models--"):]
|
||||
repo_name = repo_dir.name[len("models--") :]
|
||||
if not repo_name:
|
||||
continue
|
||||
model_id = repo_name.replace("--", "/")
|
||||
|
|
@ -165,28 +190,53 @@ def _scan_hf_cache(cache_dir: Path) -> List[LocalModelInfo]:
|
|||
|
||||
found.append(
|
||||
LocalModelInfo(
|
||||
id=model_id,
|
||||
model_id=model_id,
|
||||
display_name=model_id.split("/")[-1],
|
||||
path=str(repo_dir),
|
||||
source="hf_cache",
|
||||
updated_at=updated_at,
|
||||
id = model_id,
|
||||
model_id = model_id,
|
||||
display_name = model_id.split("/")[-1],
|
||||
path = str(repo_dir),
|
||||
source = "hf_cache",
|
||||
updated_at = updated_at,
|
||||
),
|
||||
)
|
||||
return found
|
||||
|
||||
|
||||
@router.get("/local", response_model=LocalModelListResponse)
|
||||
@router.get("/local", response_model = LocalModelListResponse)
|
||||
async def list_local_models(
|
||||
models_dir: str = Query(default="./models", description="Directory to scan for local model folders"),
|
||||
models_dir: str = Query(
|
||||
default = "./models", description = "Directory to scan for local model folders"
|
||||
),
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
):
|
||||
"""
|
||||
List local model candidates from custom models dir and HF cache.
|
||||
"""
|
||||
# Validate models_dir against an allowlist of trusted directories.
|
||||
# Only the trusted Path objects are used for filesystem access -- the
|
||||
# user-supplied string is only used for matching, never for path construction.
|
||||
hf_cache_dir = _resolve_hf_cache_dir()
|
||||
allowed_roots = [Path("./models").resolve(), hf_cache_dir]
|
||||
try:
|
||||
from utils.paths import studio_root, outputs_root
|
||||
|
||||
allowed_roots.extend([studio_root(), outputs_root()])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
requested = os.path.realpath(os.path.expanduser(models_dir))
|
||||
models_root = None
|
||||
for root in allowed_roots:
|
||||
root_str = os.path.realpath(str(root))
|
||||
if requested == root_str or requested.startswith(root_str + os.sep):
|
||||
models_root = root # Use the trusted root, not the user-supplied path
|
||||
break
|
||||
if models_root is None:
|
||||
raise HTTPException(
|
||||
status_code = 403,
|
||||
detail = "Directory not allowed",
|
||||
)
|
||||
|
||||
try:
|
||||
models_root = Path(models_dir).expanduser().resolve()
|
||||
hf_cache_dir = _resolve_hf_cache_dir()
|
||||
local_models = _scan_models_dir(models_root) + _scan_hf_cache(hf_cache_dir)
|
||||
|
||||
deduped: dict[str, LocalModelInfo] = {}
|
||||
|
|
@ -196,88 +246,80 @@ async def list_local_models(
|
|||
|
||||
models = sorted(
|
||||
deduped.values(),
|
||||
key=lambda item: (item.updated_at or 0),
|
||||
reverse=True,
|
||||
key = lambda item: (item.updated_at or 0),
|
||||
reverse = True,
|
||||
)
|
||||
|
||||
return LocalModelListResponse(
|
||||
models_dir=str(models_root),
|
||||
hf_cache_dir=str(hf_cache_dir),
|
||||
models=models,
|
||||
models_dir = str(models_root),
|
||||
hf_cache_dir = str(hf_cache_dir),
|
||||
models = models,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing local models: {e}", exc_info=True)
|
||||
logger.error(f"Error listing local models: {e}", exc_info = True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to list local models: {str(e)}",
|
||||
status_code = 500,
|
||||
detail = f"Failed to list local models: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/list")
|
||||
async def list_models(
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
):
|
||||
"""
|
||||
List available models (default models and loaded models).
|
||||
|
||||
|
||||
This endpoint returns the default models and any currently loaded models.
|
||||
"""
|
||||
try:
|
||||
inference_backend = get_inference_backend()
|
||||
|
||||
|
||||
# Get default models
|
||||
default_models = inference_backend.default_models
|
||||
|
||||
|
||||
# Get loaded models
|
||||
loaded_models = []
|
||||
for model_name, model_data in inference_backend.models.items():
|
||||
_is_vision = model_data.get("is_vision", False)
|
||||
_audio_type = model_data.get("audio_type")
|
||||
model_info = ModelDetails(
|
||||
id=model_name,
|
||||
name=model_name.split("/")[-1] if "/" in model_name else model_name,
|
||||
is_vision=_is_vision,
|
||||
is_lora=model_data.get("is_lora", False),
|
||||
is_audio=model_data.get("is_audio", False),
|
||||
audio_type=_audio_type,
|
||||
has_audio_input=model_data.get("has_audio_input", False),
|
||||
model_type=derive_model_type(_is_vision, _audio_type),
|
||||
id = model_name,
|
||||
name = model_name.split("/")[-1] if "/" in model_name else model_name,
|
||||
is_vision = _is_vision,
|
||||
is_lora = model_data.get("is_lora", False),
|
||||
is_audio = model_data.get("is_audio", False),
|
||||
audio_type = _audio_type,
|
||||
has_audio_input = model_data.get("has_audio_input", False),
|
||||
model_type = derive_model_type(_is_vision, _audio_type),
|
||||
)
|
||||
loaded_models.append(model_info)
|
||||
|
||||
|
||||
# Combine default and loaded models
|
||||
all_models = []
|
||||
seen_ids = set()
|
||||
|
||||
|
||||
# Add default models
|
||||
for model_id in default_models:
|
||||
if model_id not in seen_ids:
|
||||
model_info = ModelDetails(
|
||||
id=model_id,
|
||||
name=model_id.split("/")[-1] if "/" in model_id else model_id
|
||||
id = model_id,
|
||||
name = model_id.split("/")[-1] if "/" in model_id else model_id,
|
||||
)
|
||||
all_models.append(model_info)
|
||||
seen_ids.add(model_id)
|
||||
|
||||
|
||||
# Add loaded models
|
||||
for model_info in loaded_models:
|
||||
if model_info.id not in seen_ids:
|
||||
all_models.append(model_info)
|
||||
seen_ids.add(model_info.id)
|
||||
|
||||
return ModelListResponse(
|
||||
models=all_models,
|
||||
default_models=default_models
|
||||
)
|
||||
|
||||
|
||||
return ModelListResponse(models = all_models, default_models = default_models)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing models: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to list models: {str(e)}"
|
||||
)
|
||||
logger.error(f"Error listing models: {e}", exc_info = True)
|
||||
raise HTTPException(status_code = 500, detail = f"Failed to list models: {str(e)}")
|
||||
|
||||
|
||||
@router.get("/config/{model_name:path}")
|
||||
|
|
@ -293,18 +335,20 @@ async def get_model_config(
|
|||
"""
|
||||
try:
|
||||
from utils.models.model_config import is_local_path
|
||||
|
||||
if not is_local_path(model_name):
|
||||
model_name = model_name.lower()
|
||||
|
||||
|
||||
logger.info(f"Getting model config for: {model_name}")
|
||||
from utils.models.model_config import detect_audio_type
|
||||
|
||||
# Load model defaults from backend
|
||||
config_dict = load_model_defaults(model_name)
|
||||
|
||||
# Detect model capabilities (pass HF token for gated models)
|
||||
is_vision = is_vision_model(model_name)
|
||||
is_embedding = is_embedding_model(model_name, hf_token=hf_token)
|
||||
audio_type = detect_audio_type(model_name, hf_token=hf_token)
|
||||
is_embedding = is_embedding_model(model_name, hf_token = hf_token)
|
||||
audio_type = detect_audio_type(model_name, hf_token = hf_token)
|
||||
|
||||
# Check if it's a LoRA adapter
|
||||
is_lora = False
|
||||
|
|
@ -316,33 +360,38 @@ async def get_model_config(
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
logger.info(f"Model config result for {model_name}: is_vision={is_vision}, is_embedding={is_embedding}, audio_type={audio_type}, is_lora={is_lora}")
|
||||
return ModelDetails(
|
||||
id=model_name,
|
||||
model_name=model_name,
|
||||
config=config_dict,
|
||||
is_vision=is_vision,
|
||||
is_embedding=is_embedding,
|
||||
is_lora=is_lora,
|
||||
is_audio=audio_type is not None,
|
||||
audio_type=audio_type,
|
||||
has_audio_input=is_audio_input_type(audio_type),
|
||||
model_type=derive_model_type(is_vision, audio_type, is_embedding),
|
||||
base_model=base_model,
|
||||
logger.info(
|
||||
f"Model config result for {model_name}: is_vision={is_vision}, is_embedding={is_embedding}, audio_type={audio_type}, is_lora={is_lora}"
|
||||
)
|
||||
|
||||
return ModelDetails(
|
||||
id = model_name,
|
||||
model_name = model_name,
|
||||
config = config_dict,
|
||||
is_vision = is_vision,
|
||||
is_embedding = is_embedding,
|
||||
is_lora = is_lora,
|
||||
is_audio = audio_type is not None,
|
||||
audio_type = audio_type,
|
||||
has_audio_input = is_audio_input_type(audio_type),
|
||||
model_type = derive_model_type(is_vision, audio_type, is_embedding),
|
||||
base_model = base_model,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting model config: {e}", exc_info=True)
|
||||
logger.error(f"Error getting model config: {e}", exc_info = True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to get model config: {str(e)}"
|
||||
status_code = 500, detail = f"Failed to get model config: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/loras")
|
||||
async def scan_loras(
|
||||
outputs_dir: str = Query(default=str(outputs_root()), description="Directory to scan for LoRA adapters"),
|
||||
exports_dir: str = Query(default=str(exports_root()), description="Directory to scan for exported models"),
|
||||
outputs_dir: str = Query(
|
||||
default = str(outputs_root()), description = "Directory to scan for LoRA adapters"
|
||||
),
|
||||
exports_dir: str = Query(
|
||||
default = str(exports_root()), description = "Directory to scan for exported models"
|
||||
),
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
):
|
||||
"""
|
||||
|
|
@ -357,102 +406,101 @@ async def scan_loras(
|
|||
lora_list = []
|
||||
|
||||
# Scan training outputs
|
||||
trained_loras = scan_trained_loras(outputs_dir=resolved_outputs_dir)
|
||||
trained_loras = scan_trained_loras(outputs_dir = resolved_outputs_dir)
|
||||
for display_name, adapter_path in trained_loras:
|
||||
base_model = get_base_model_from_lora(adapter_path)
|
||||
lora_list.append(LoRAInfo(
|
||||
display_name=display_name,
|
||||
adapter_path=adapter_path,
|
||||
base_model=base_model,
|
||||
source="training",
|
||||
))
|
||||
lora_list.append(
|
||||
LoRAInfo(
|
||||
display_name = display_name,
|
||||
adapter_path = adapter_path,
|
||||
base_model = base_model,
|
||||
source = "training",
|
||||
)
|
||||
)
|
||||
|
||||
# Scan exported models (merged, LoRA, base — skips GGUF)
|
||||
exported = scan_exported_models(exports_dir=resolved_exports_dir)
|
||||
exported = scan_exported_models(exports_dir = resolved_exports_dir)
|
||||
for display_name, model_path, export_type, base_model in exported:
|
||||
lora_list.append(LoRAInfo(
|
||||
display_name=display_name,
|
||||
adapter_path=model_path,
|
||||
base_model=base_model,
|
||||
source="exported",
|
||||
export_type=export_type,
|
||||
))
|
||||
lora_list.append(
|
||||
LoRAInfo(
|
||||
display_name = display_name,
|
||||
adapter_path = model_path,
|
||||
base_model = base_model,
|
||||
source = "exported",
|
||||
export_type = export_type,
|
||||
)
|
||||
)
|
||||
|
||||
return LoRAScanResponse(
|
||||
loras=lora_list,
|
||||
outputs_dir=resolved_outputs_dir
|
||||
)
|
||||
return LoRAScanResponse(loras = lora_list, outputs_dir = resolved_outputs_dir)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error scanning LoRAs: {e}", exc_info=True)
|
||||
logger.error(f"Error scanning LoRAs: {e}", exc_info = True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to scan LoRA adapters: {str(e)}"
|
||||
status_code = 500, detail = f"Failed to scan LoRA adapters: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/loras/{lora_path:path}/base-model", response_model=LoRABaseModelResponse)
|
||||
@router.get("/loras/{lora_path:path}/base-model", response_model = LoRABaseModelResponse)
|
||||
async def get_lora_base_model(
|
||||
lora_path: str,
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
):
|
||||
"""
|
||||
Get the base model for a LoRA adapter.
|
||||
|
||||
|
||||
This endpoint wraps the backend get_base_model_from_lora function.
|
||||
"""
|
||||
try:
|
||||
base_model = get_base_model_from_lora(lora_path)
|
||||
|
||||
|
||||
if base_model is None:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Could not determine base model for LoRA: {lora_path}"
|
||||
status_code = 404,
|
||||
detail = f"Could not determine base model for LoRA: {lora_path}",
|
||||
)
|
||||
|
||||
|
||||
return LoRABaseModelResponse(
|
||||
lora_path=lora_path,
|
||||
base_model=base_model,
|
||||
lora_path = lora_path,
|
||||
base_model = base_model,
|
||||
)
|
||||
|
||||
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting LoRA base model: {e}", exc_info=True)
|
||||
logger.error(f"Error getting LoRA base model: {e}", exc_info = True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to get base model: {str(e)}"
|
||||
status_code = 500, detail = f"Failed to get base model: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/check-vision/{model_name:path}", response_model=VisionCheckResponse)
|
||||
@router.get("/check-vision/{model_name:path}", response_model = VisionCheckResponse)
|
||||
async def check_vision_model(
|
||||
model_name: str,
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
):
|
||||
"""
|
||||
Check if a model is a vision model.
|
||||
|
||||
|
||||
This endpoint wraps the backend is_vision_model function.
|
||||
"""
|
||||
try:
|
||||
logger.info(f"Checking if vision model: {model_name}")
|
||||
is_vision = is_vision_model(model_name)
|
||||
|
||||
|
||||
logger.info(f"Vision check result for {model_name}: is_vision={is_vision}")
|
||||
return VisionCheckResponse(
|
||||
model_name=model_name,
|
||||
is_vision=is_vision,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking vision model: {e}", exc_info=True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to check vision model: {str(e)}"
|
||||
model_name = model_name,
|
||||
is_vision = is_vision,
|
||||
)
|
||||
|
||||
@router.get("/check-embedding/{model_name:path}", response_model=EmbeddingCheckResponse)
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking vision model: {e}", exc_info = True)
|
||||
raise HTTPException(
|
||||
status_code = 500, detail = f"Failed to check vision model: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/check-embedding/{model_name:path}", response_model = EmbeddingCheckResponse)
|
||||
async def check_embedding_model(
|
||||
model_name: str,
|
||||
hf_token: Optional[str] = Query(None),
|
||||
|
|
@ -465,26 +513,31 @@ async def check_embedding_model(
|
|||
"""
|
||||
try:
|
||||
logger.info(f"Checking if embedding model: {model_name}")
|
||||
is_embedding = is_embedding_model(model_name, hf_token=hf_token)
|
||||
is_embedding = is_embedding_model(model_name, hf_token = hf_token)
|
||||
|
||||
logger.info(f"Embedding check result for {model_name}: is_embedding={is_embedding}")
|
||||
logger.info(
|
||||
f"Embedding check result for {model_name}: is_embedding={is_embedding}"
|
||||
)
|
||||
return EmbeddingCheckResponse(
|
||||
model_name=model_name,
|
||||
is_embedding=is_embedding,
|
||||
model_name = model_name,
|
||||
is_embedding = is_embedding,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking embedding model: {e}", exc_info=True)
|
||||
logger.error(f"Error checking embedding model: {e}", exc_info = True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to check embedding model: {str(e)}"
|
||||
status_code = 500, detail = f"Failed to check embedding model: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/gguf-variants", response_model=GgufVariantsResponse)
|
||||
@router.get("/gguf-variants", response_model = GgufVariantsResponse)
|
||||
async def get_gguf_variants(
|
||||
repo_id: str = Query(..., description="HuggingFace repo ID (e.g. 'unsloth/gemma-3-4b-it-GGUF')"),
|
||||
hf_token: Optional[str] = Query(None, description="HuggingFace token for private repos"),
|
||||
repo_id: str = Query(
|
||||
..., description = "HuggingFace repo ID (e.g. 'unsloth/gemma-3-4b-it-GGUF')"
|
||||
),
|
||||
hf_token: Optional[str] = Query(
|
||||
None, description = "HuggingFace token for private repos"
|
||||
),
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
):
|
||||
"""
|
||||
|
|
@ -495,7 +548,7 @@ async def get_gguf_variants(
|
|||
default variant.
|
||||
"""
|
||||
try:
|
||||
variants, has_vision = list_gguf_variants(repo_id, hf_token=hf_token)
|
||||
variants, has_vision = list_gguf_variants(repo_id, hf_token = hf_token)
|
||||
|
||||
# Determine default variant
|
||||
filenames = [v.filename for v in variants]
|
||||
|
|
@ -503,32 +556,32 @@ async def get_gguf_variants(
|
|||
default_variant = _extract_quant_label(best) if best else None
|
||||
|
||||
return GgufVariantsResponse(
|
||||
repo_id=repo_id,
|
||||
variants=[
|
||||
repo_id = repo_id,
|
||||
variants = [
|
||||
GgufVariantDetail(
|
||||
filename=v.filename,
|
||||
quant=v.quant,
|
||||
size_bytes=v.size_bytes,
|
||||
filename = v.filename,
|
||||
quant = v.quant,
|
||||
size_bytes = v.size_bytes,
|
||||
)
|
||||
for v in variants
|
||||
],
|
||||
has_vision=has_vision,
|
||||
default_variant=default_variant,
|
||||
has_vision = has_vision,
|
||||
default_variant = default_variant,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing GGUF variants for '{repo_id}': {e}", exc_info=True)
|
||||
logger.error(f"Error listing GGUF variants for '{repo_id}': {e}", exc_info = True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to list GGUF variants: {str(e)}",
|
||||
status_code = 500,
|
||||
detail = f"Failed to list GGUF variants: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/checkpoints", response_model=CheckpointListResponse)
|
||||
@router.get("/checkpoints", response_model = CheckpointListResponse)
|
||||
async def list_checkpoints(
|
||||
outputs_dir: str = Query(
|
||||
default=str(outputs_root()),
|
||||
description="Directory to scan for checkpoints",
|
||||
default = str(outputs_root()),
|
||||
description = "Directory to scan for checkpoints",
|
||||
),
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
):
|
||||
|
|
@ -539,29 +592,29 @@ async def list_checkpoints(
|
|||
"""
|
||||
try:
|
||||
resolved_outputs_dir = str(resolve_output_dir(outputs_dir))
|
||||
raw_models = scan_checkpoints(outputs_dir=resolved_outputs_dir)
|
||||
raw_models = scan_checkpoints(outputs_dir = resolved_outputs_dir)
|
||||
|
||||
models = [
|
||||
ModelCheckpoints(
|
||||
name=model_name,
|
||||
checkpoints=[
|
||||
CheckpointInfo(display_name=display_name, path=path, loss=loss)
|
||||
name = model_name,
|
||||
checkpoints = [
|
||||
CheckpointInfo(display_name = display_name, path = path, loss = loss)
|
||||
for display_name, path, loss in checkpoints
|
||||
],
|
||||
base_model=metadata.get("base_model"),
|
||||
peft_type=metadata.get("peft_type"),
|
||||
lora_rank=metadata.get("lora_rank"),
|
||||
base_model = metadata.get("base_model"),
|
||||
peft_type = metadata.get("peft_type"),
|
||||
lora_rank = metadata.get("lora_rank"),
|
||||
)
|
||||
for model_name, checkpoints, metadata in raw_models
|
||||
]
|
||||
|
||||
return CheckpointListResponse(
|
||||
outputs_dir=resolved_outputs_dir,
|
||||
models=models,
|
||||
outputs_dir = resolved_outputs_dir,
|
||||
models = models,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"Error listing checkpoints: {e}", exc_info=True)
|
||||
logger.error(f"Error listing checkpoints: {e}", exc_info = True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to list checkpoints: {str(e)}",
|
||||
status_code = 500,
|
||||
detail = f"Failed to list checkpoints: {str(e)}",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"""
|
||||
Training API routes
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
|
|
@ -50,12 +51,11 @@ from pydantic import BaseModel as PydanticBaseModel
|
|||
class TrainingStopRequest(PydanticBaseModel):
|
||||
save: bool = True
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
|
||||
|
||||
@router.get("/hardware")
|
||||
async def get_hardware_utilization(
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
|
|
@ -100,13 +100,13 @@ async def start_training(
|
|||
if backend.is_training_active():
|
||||
existing_job_id: Optional[str] = getattr(backend, "current_job_id", "")
|
||||
return TrainingJobResponse(
|
||||
job_id=existing_job_id or job_id,
|
||||
status="error",
|
||||
message=(
|
||||
job_id = existing_job_id or job_id,
|
||||
status = "error",
|
||||
message = (
|
||||
"Training is already in progress. "
|
||||
"Stop current training before starting a new one."
|
||||
),
|
||||
error="Training already active",
|
||||
error = "Training already active",
|
||||
)
|
||||
|
||||
# Validate dataset paths if provided
|
||||
|
|
@ -128,8 +128,8 @@ async def start_training(
|
|||
if missing_datasets:
|
||||
missing_detail = "; ".join(missing_datasets[:3])
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Local dataset not found: {missing_detail}",
|
||||
status_code = 400,
|
||||
detail = f"Local dataset not found: {missing_detail}",
|
||||
)
|
||||
request.local_datasets = validated_datasets
|
||||
|
||||
|
|
@ -167,7 +167,9 @@ async def start_training(
|
|||
"lora_r": request.lora_r,
|
||||
"lora_alpha": request.lora_alpha,
|
||||
"lora_dropout": request.lora_dropout,
|
||||
"target_modules": request.target_modules if request.target_modules else None,
|
||||
"target_modules": request.target_modules
|
||||
if request.target_modules
|
||||
else None,
|
||||
"gradient_checkpointing": request.gradient_checkpointing.strip()
|
||||
if request.gradient_checkpointing and request.gradient_checkpointing.strip()
|
||||
else "unsloth",
|
||||
|
|
@ -194,15 +196,20 @@ async def start_training(
|
|||
# net, consult the YAML directly so models that need it always get it.
|
||||
if not training_kwargs["trust_remote_code"]:
|
||||
model_defaults = load_model_defaults(request.model_name)
|
||||
yaml_trust = model_defaults.get("training", {}).get("trust_remote_code", False)
|
||||
yaml_trust = model_defaults.get("training", {}).get(
|
||||
"trust_remote_code", False
|
||||
)
|
||||
if yaml_trust:
|
||||
logger.info(f"YAML config sets trust_remote_code=True for {request.model_name}")
|
||||
logger.info(
|
||||
f"YAML config sets trust_remote_code=True for {request.model_name}"
|
||||
)
|
||||
training_kwargs["trust_remote_code"] = True
|
||||
|
||||
# Free GPU memory: shut down any running inference/export subprocesses
|
||||
# before training starts (they'd compete for VRAM otherwise)
|
||||
try:
|
||||
from core.inference import get_inference_backend
|
||||
|
||||
inf_backend = get_inference_backend()
|
||||
if inf_backend.active_model_name:
|
||||
logger.info(
|
||||
|
|
@ -217,9 +224,12 @@ async def start_training(
|
|||
|
||||
try:
|
||||
from core.export import get_export_backend
|
||||
|
||||
exp_backend = get_export_backend()
|
||||
if exp_backend.current_checkpoint:
|
||||
logger.info("Shutting down export subprocess to free GPU memory for training")
|
||||
logger.info(
|
||||
"Shutting down export subprocess to free GPU memory for training"
|
||||
)
|
||||
exp_backend._shutdown_subprocess()
|
||||
exp_backend.current_checkpoint = None
|
||||
exp_backend.is_vision = False
|
||||
|
|
@ -233,28 +243,28 @@ async def start_training(
|
|||
if not success:
|
||||
progress_error = backend.trainer.training_progress.error
|
||||
return TrainingJobResponse(
|
||||
job_id=job_id,
|
||||
status="error",
|
||||
message=progress_error or "Failed to start training subprocess",
|
||||
error=progress_error or "subprocess_start_failed",
|
||||
job_id = job_id,
|
||||
status = "error",
|
||||
message = progress_error or "Failed to start training subprocess",
|
||||
error = progress_error or "subprocess_start_failed",
|
||||
)
|
||||
|
||||
return TrainingJobResponse(
|
||||
job_id=job_id,
|
||||
status="queued",
|
||||
message="Training job queued and starting in subprocess",
|
||||
error=None,
|
||||
job_id = job_id,
|
||||
status = "queued",
|
||||
message = "Training job queued and starting in subprocess",
|
||||
error = None,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error starting training: {e}", exc_info=True)
|
||||
logger.error(f"Error starting training: {e}", exc_info = True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to start training: {str(e)}",
|
||||
status_code = 500,
|
||||
detail = f"Failed to start training: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/stop", response_model=TrainingStopResponse)
|
||||
@router.post("/stop", response_model = TrainingStopResponse)
|
||||
async def stop_training(
|
||||
body: TrainingStopRequest = TrainingStopRequest(),
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
|
|
@ -272,23 +282,21 @@ async def stop_training(
|
|||
|
||||
if not is_active:
|
||||
return TrainingStopResponse(
|
||||
status="idle",
|
||||
message="No training job is currently running"
|
||||
status = "idle", message = "No training job is currently running"
|
||||
)
|
||||
|
||||
# Call backend stop method
|
||||
backend.stop_training(save=body.save)
|
||||
backend.stop_training(save = body.save)
|
||||
|
||||
return TrainingStopResponse(
|
||||
status="stopped",
|
||||
message="Stop requested. Training will stop at the next safe step."
|
||||
status = "stopped",
|
||||
message = "Stop requested. Training will stop at the next safe step.",
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping training: {e}", exc_info=True)
|
||||
logger.error(f"Error stopping training: {e}", exc_info = True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to stop training: {str(e)}"
|
||||
status_code = 500, detail = f"Failed to stop training: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -306,21 +314,30 @@ async def reset_training(
|
|||
if is_active:
|
||||
if backend._cancel_requested:
|
||||
# Cancel (save=False) was requested — force-terminate so we can reset immediately
|
||||
logger.info("Force-terminating subprocess for immediate reset (cancel path)")
|
||||
logger.info(
|
||||
"Force-terminating subprocess for immediate reset (cancel path)"
|
||||
)
|
||||
backend.force_terminate()
|
||||
else:
|
||||
logger.warning("Rejected reset while training active: is_active=%s", is_active)
|
||||
logger.warning(
|
||||
"Rejected reset while training active: is_active=%s", is_active
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="Training is still running. Stop training and wait for it to finish before resetting.",
|
||||
status_code = 409,
|
||||
detail = "Training is still running. Stop training and wait for it to finish before resetting.",
|
||||
)
|
||||
|
||||
logger.info("Reset training state: clearing runtime + metric history")
|
||||
backend._should_stop = False # Clear stop flag so status returns to idle
|
||||
backend.trainer._update_progress(
|
||||
is_training=False, is_completed=False, error=None,
|
||||
status_message="Ready to train", step=0, loss=0.0, epoch=0,
|
||||
total_steps=0,
|
||||
is_training = False,
|
||||
is_completed = False,
|
||||
error = None,
|
||||
status_message = "Ready to train",
|
||||
step = 0,
|
||||
loss = 0.0,
|
||||
epoch = 0,
|
||||
total_steps = 0,
|
||||
)
|
||||
backend.loss_history = []
|
||||
backend.lr_history = []
|
||||
|
|
@ -331,10 +348,10 @@ async def reset_training(
|
|||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error resetting training: {e}", exc_info=True)
|
||||
logger.error(f"Error resetting training: {e}", exc_info = True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to reset training: {str(e)}",
|
||||
status_code = 500,
|
||||
detail = f"Failed to reset training: {str(e)}",
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -410,25 +427,24 @@ async def get_training_status(
|
|||
}
|
||||
|
||||
return TrainingStatus(
|
||||
job_id=job_id,
|
||||
phase=phase,
|
||||
is_training_running=is_active,
|
||||
eval_enabled=backend.eval_enabled,
|
||||
message=status_message,
|
||||
error=error_message,
|
||||
details=details,
|
||||
metric_history=metric_history,
|
||||
job_id = job_id,
|
||||
phase = phase,
|
||||
is_training_running = is_active,
|
||||
eval_enabled = backend.eval_enabled,
|
||||
message = status_message,
|
||||
error = error_message,
|
||||
details = details,
|
||||
metric_history = metric_history,
|
||||
)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting training status: {e}", exc_info=True)
|
||||
logger.error(f"Error getting training status: {e}", exc_info = True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to get training status: {str(e)}"
|
||||
status_code = 500, detail = f"Failed to get training status: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/metrics", response_model=TrainingMetricsResponse)
|
||||
@router.get("/metrics", response_model = TrainingMetricsResponse)
|
||||
async def get_training_metrics(
|
||||
current_subject: str = Depends(get_current_subject),
|
||||
):
|
||||
|
|
@ -437,7 +453,7 @@ async def get_training_metrics(
|
|||
"""
|
||||
try:
|
||||
backend = get_training_backend()
|
||||
|
||||
|
||||
# Get metrics from backend
|
||||
loss_history = backend.loss_history
|
||||
lr_history = backend.lr_history
|
||||
|
|
@ -451,21 +467,20 @@ async def get_training_metrics(
|
|||
current_step = step_history[-1] if step_history else None
|
||||
|
||||
return TrainingMetricsResponse(
|
||||
loss_history=loss_history,
|
||||
lr_history=lr_history,
|
||||
step_history=step_history,
|
||||
grad_norm_history=grad_norm_history,
|
||||
grad_norm_step_history=grad_norm_step_history,
|
||||
current_loss=current_loss,
|
||||
current_lr=current_lr,
|
||||
current_step=current_step,
|
||||
loss_history = loss_history,
|
||||
lr_history = lr_history,
|
||||
step_history = step_history,
|
||||
grad_norm_history = grad_norm_history,
|
||||
grad_norm_step_history = grad_norm_step_history,
|
||||
current_loss = current_loss,
|
||||
current_lr = current_lr,
|
||||
current_step = current_step,
|
||||
)
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error getting training metrics: {e}", exc_info=True)
|
||||
logger.error(f"Error getting training metrics: {e}", exc_info = True)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to get training metrics: {str(e)}"
|
||||
status_code = 500, detail = f"Failed to get training metrics: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -518,29 +533,31 @@ async def stream_training_progress(
|
|||
)
|
||||
|
||||
# Get actual values from progress object if available
|
||||
elapsed_seconds = getattr(progress, 'elapsed_seconds', None) if progress else None
|
||||
eta_seconds = getattr(progress, 'eta_seconds', None) if progress else None
|
||||
elapsed_seconds = (
|
||||
getattr(progress, "elapsed_seconds", None) if progress else None
|
||||
)
|
||||
eta_seconds = getattr(progress, "eta_seconds", None) if progress else None
|
||||
grad_norm = grad_norm_override
|
||||
if grad_norm is None and progress:
|
||||
grad_norm = getattr(progress, 'grad_norm', None)
|
||||
num_tokens = getattr(progress, 'num_tokens', None) if progress else None
|
||||
grad_norm = getattr(progress, "grad_norm", None)
|
||||
num_tokens = getattr(progress, "num_tokens", None) if progress else None
|
||||
eval_loss = eval_loss_override
|
||||
if eval_loss is None and progress:
|
||||
eval_loss = getattr(progress, 'eval_loss', None)
|
||||
eval_loss = getattr(progress, "eval_loss", None)
|
||||
|
||||
return TrainingProgress(
|
||||
job_id=job_id,
|
||||
step=step,
|
||||
total_steps=total,
|
||||
loss=loss,
|
||||
learning_rate=learning_rate,
|
||||
progress_percent=progress_percent,
|
||||
epoch=epoch,
|
||||
elapsed_seconds=elapsed_seconds,
|
||||
eta_seconds=eta_seconds,
|
||||
grad_norm=grad_norm,
|
||||
num_tokens=num_tokens,
|
||||
eval_loss=eval_loss,
|
||||
job_id = job_id,
|
||||
step = step,
|
||||
total_steps = total,
|
||||
loss = loss,
|
||||
learning_rate = learning_rate,
|
||||
progress_percent = progress_percent,
|
||||
epoch = epoch,
|
||||
elapsed_seconds = elapsed_seconds,
|
||||
eta_seconds = eta_seconds,
|
||||
grad_norm = grad_norm,
|
||||
num_tokens = num_tokens,
|
||||
eval_loss = eval_loss,
|
||||
)
|
||||
|
||||
def format_sse(
|
||||
|
|
@ -574,23 +591,37 @@ async def stream_training_progress(
|
|||
}
|
||||
for i, step_val in enumerate(backend.step_history):
|
||||
if step_val > resume_from_step:
|
||||
loss_val = backend.loss_history[i] if i < len(backend.loss_history) else 0.0
|
||||
lr_val = backend.lr_history[i] if i < len(backend.lr_history) else 0.0
|
||||
loss_val = (
|
||||
backend.loss_history[i]
|
||||
if i < len(backend.loss_history)
|
||||
else 0.0
|
||||
)
|
||||
lr_val = (
|
||||
backend.lr_history[i] if i < len(backend.lr_history) else 0.0
|
||||
)
|
||||
tp_replay = getattr(
|
||||
getattr(backend, "trainer", None), "training_progress", None
|
||||
)
|
||||
total_replay = getattr(tp_replay, "total_steps", step_val) if tp_replay else step_val
|
||||
epoch_replay = getattr(tp_replay, "epoch", None) if tp_replay else None
|
||||
total_replay = (
|
||||
getattr(tp_replay, "total_steps", step_val)
|
||||
if tp_replay
|
||||
else step_val
|
||||
)
|
||||
epoch_replay = (
|
||||
getattr(tp_replay, "epoch", None) if tp_replay else None
|
||||
)
|
||||
payload = build_progress(
|
||||
step_val,
|
||||
loss_val,
|
||||
lr_val,
|
||||
total_replay,
|
||||
epoch_replay,
|
||||
progress=tp_replay,
|
||||
grad_norm_override=grad_norm_by_step.get(step_val),
|
||||
progress = tp_replay,
|
||||
grad_norm_override = grad_norm_by_step.get(step_val),
|
||||
)
|
||||
yield format_sse(
|
||||
payload.model_dump_json(), event = "progress", event_id = step_val
|
||||
)
|
||||
yield format_sse(payload.model_dump_json(), event="progress", event_id=step_val)
|
||||
replayed += 1
|
||||
if replayed:
|
||||
logger.info(f"SSE reconnect: replayed {replayed} missed steps")
|
||||
|
|
@ -603,45 +634,62 @@ async def stream_training_progress(
|
|||
initial_epoch = getattr(tp, "epoch", None) if tp else None
|
||||
|
||||
initial_progress = build_progress(
|
||||
step=0,
|
||||
loss=0.0,
|
||||
learning_rate=0.0,
|
||||
total_steps=initial_total_steps,
|
||||
epoch=initial_epoch,
|
||||
progress=tp,
|
||||
step = 0,
|
||||
loss = 0.0,
|
||||
learning_rate = 0.0,
|
||||
total_steps = initial_total_steps,
|
||||
epoch = initial_epoch,
|
||||
progress = tp,
|
||||
)
|
||||
yield format_sse(
|
||||
initial_progress.model_dump_json(), event = "progress", event_id = 0
|
||||
)
|
||||
yield format_sse(initial_progress.model_dump_json(), event="progress", event_id=0)
|
||||
|
||||
# If not active, send final state and exit
|
||||
if not is_active:
|
||||
if backend.step_history:
|
||||
final_step = backend.step_history[-1]
|
||||
final_loss = backend.loss_history[-1] if backend.loss_history else 0.0
|
||||
final_loss = (
|
||||
backend.loss_history[-1] if backend.loss_history else 0.0
|
||||
)
|
||||
final_lr = backend.lr_history[-1] if backend.lr_history else 0.0
|
||||
final_total_steps = (
|
||||
getattr(tp, "total_steps", final_step) if tp else final_step
|
||||
)
|
||||
final_epoch = getattr(tp, "epoch", None) if tp else None
|
||||
payload = build_progress(final_step, final_loss, final_lr, final_total_steps, final_epoch, progress=tp)
|
||||
yield format_sse(payload.model_dump_json(), event="complete", event_id=final_step)
|
||||
payload = build_progress(
|
||||
final_step,
|
||||
final_loss,
|
||||
final_lr,
|
||||
final_total_steps,
|
||||
final_epoch,
|
||||
progress = tp,
|
||||
)
|
||||
yield format_sse(
|
||||
payload.model_dump_json(), event = "complete", event_id = final_step
|
||||
)
|
||||
else:
|
||||
yield format_sse(
|
||||
build_progress(-1, 0.0, 0.0, 0, progress=tp).model_dump_json(),
|
||||
event="complete",
|
||||
event_id=0,
|
||||
build_progress(-1, 0.0, 0.0, 0, progress = tp).model_dump_json(),
|
||||
event = "complete",
|
||||
event_id = 0,
|
||||
)
|
||||
return
|
||||
|
||||
# ── Live polling loop ────────────────────────────────────
|
||||
last_step = resume_from_step if resume_from_step is not None else -1
|
||||
no_update_count = 0
|
||||
max_no_updates = 1800 # Timeout after 30 minutes (large models need time for compilation)
|
||||
max_no_updates = (
|
||||
1800 # Timeout after 30 minutes (large models need time for compilation)
|
||||
)
|
||||
|
||||
while backend.is_training_active():
|
||||
try:
|
||||
if backend.step_history:
|
||||
current_step = backend.step_history[-1]
|
||||
current_loss = backend.loss_history[-1] if backend.loss_history else 0.0
|
||||
current_loss = (
|
||||
backend.loss_history[-1] if backend.loss_history else 0.0
|
||||
)
|
||||
current_lr = backend.lr_history[-1] if backend.lr_history else 0.0
|
||||
tp_inner = getattr(
|
||||
getattr(backend, "trainer", None), "training_progress", None
|
||||
|
|
@ -651,7 +699,9 @@ async def stream_training_progress(
|
|||
if tp_inner
|
||||
else current_step
|
||||
)
|
||||
current_epoch = getattr(tp_inner, "epoch", None) if tp_inner else None
|
||||
current_epoch = (
|
||||
getattr(tp_inner, "epoch", None) if tp_inner else None
|
||||
)
|
||||
|
||||
# Only send if step changed
|
||||
if current_step != last_step:
|
||||
|
|
@ -661,12 +711,12 @@ async def stream_training_progress(
|
|||
current_lr,
|
||||
current_total_steps,
|
||||
current_epoch,
|
||||
progress=tp_inner,
|
||||
progress = tp_inner,
|
||||
)
|
||||
yield format_sse(
|
||||
progress_payload.model_dump_json(),
|
||||
event="progress",
|
||||
event_id=current_step,
|
||||
event = "progress",
|
||||
event_id = current_step,
|
||||
)
|
||||
last_step = current_step
|
||||
no_update_count = 0
|
||||
|
|
@ -680,12 +730,12 @@ async def stream_training_progress(
|
|||
current_lr,
|
||||
current_total_steps,
|
||||
current_epoch,
|
||||
progress=tp_inner,
|
||||
progress = tp_inner,
|
||||
)
|
||||
yield format_sse(
|
||||
heartbeat_payload.model_dump_json(),
|
||||
event="heartbeat",
|
||||
event_id=current_step,
|
||||
event = "heartbeat",
|
||||
event_id = current_step,
|
||||
)
|
||||
else:
|
||||
# No steps yet, but training is active (model loading, etc.)
|
||||
|
|
@ -695,43 +745,53 @@ async def stream_training_progress(
|
|||
# the frontend can show "Tokenizing…" etc.
|
||||
tp_prep = getattr(
|
||||
getattr(backend, "trainer", None),
|
||||
"training_progress", None,
|
||||
"training_progress",
|
||||
None,
|
||||
)
|
||||
prep_total = (
|
||||
getattr(tp_prep, "total_steps", 0)
|
||||
if tp_prep else 0
|
||||
getattr(tp_prep, "total_steps", 0) if tp_prep else 0
|
||||
)
|
||||
preparing_payload = build_progress(
|
||||
0, 0.0, 0.0, prep_total, progress=tp_prep,
|
||||
0,
|
||||
0.0,
|
||||
0.0,
|
||||
prep_total,
|
||||
progress = tp_prep,
|
||||
)
|
||||
yield format_sse(
|
||||
preparing_payload.model_dump_json(),
|
||||
event="heartbeat",
|
||||
event_id=0,
|
||||
event = "heartbeat",
|
||||
event_id = 0,
|
||||
)
|
||||
|
||||
# Timeout check
|
||||
if no_update_count > max_no_updates:
|
||||
logger.warning("Progress stream timeout - no updates received")
|
||||
tp_timeout = getattr(getattr(backend, "trainer", None), "training_progress", None)
|
||||
timeout_payload = build_progress(last_step, 0.0, 0.0, 0, progress=tp_timeout)
|
||||
tp_timeout = getattr(
|
||||
getattr(backend, "trainer", None), "training_progress", None
|
||||
)
|
||||
timeout_payload = build_progress(
|
||||
last_step, 0.0, 0.0, 0, progress = tp_timeout
|
||||
)
|
||||
yield format_sse(
|
||||
timeout_payload.model_dump_json(),
|
||||
event="error",
|
||||
event_id=last_step if last_step >= 0 else 0,
|
||||
event = "error",
|
||||
event_id = last_step if last_step >= 0 else 0,
|
||||
)
|
||||
break
|
||||
|
||||
await asyncio.sleep(1) # Poll every second
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error in progress stream: {e}", exc_info=True)
|
||||
tp_error = getattr(getattr(backend, "trainer", None), "training_progress", None)
|
||||
error_payload = build_progress(0, 0.0, 0.0, 0, progress=tp_error)
|
||||
logger.error(f"Error in progress stream: {e}", exc_info = True)
|
||||
tp_error = getattr(
|
||||
getattr(backend, "trainer", None), "training_progress", None
|
||||
)
|
||||
error_payload = build_progress(0, 0.0, 0.0, 0, progress = tp_error)
|
||||
yield format_sse(
|
||||
error_payload.model_dump_json(),
|
||||
event="error",
|
||||
event_id=last_step if last_step >= 0 else 0,
|
||||
event = "error",
|
||||
event_id = last_step if last_step >= 0 else 0,
|
||||
)
|
||||
break
|
||||
|
||||
|
|
@ -739,9 +799,7 @@ async def stream_training_progress(
|
|||
final_step = backend.step_history[-1] if backend.step_history else last_step
|
||||
final_loss = backend.loss_history[-1] if backend.loss_history else 0.0
|
||||
final_lr = backend.lr_history[-1] if backend.lr_history else 0.0
|
||||
final_tp = getattr(
|
||||
getattr(backend, "trainer", None), "training_progress", None
|
||||
)
|
||||
final_tp = getattr(getattr(backend, "trainer", None), "training_progress", None)
|
||||
final_total_steps = (
|
||||
getattr(final_tp, "total_steps", final_step) if final_tp else final_step
|
||||
)
|
||||
|
|
@ -752,20 +810,20 @@ async def stream_training_progress(
|
|||
final_lr,
|
||||
final_total_steps,
|
||||
final_epoch,
|
||||
progress=final_tp,
|
||||
progress = final_tp,
|
||||
)
|
||||
yield format_sse(
|
||||
final_payload.model_dump_json(),
|
||||
event="complete",
|
||||
event_id=final_step if final_step >= 0 else 0,
|
||||
event = "complete",
|
||||
event_id = final_step if final_step >= 0 else 0,
|
||||
)
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
media_type = "text/event-stream",
|
||||
headers = {
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
|
|
|||
|
|
@ -5,11 +5,12 @@
|
|||
Run script for Unsloth UI Backend.
|
||||
Works independently and can be moved to any directory.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Suppress annoying C-level dependency warnings globally (e.g. SwigPyPacked)
|
||||
os.environ["PYTHONWARNINGS"] = "ignore"
|
||||
os.environ["PYTHONWARNINGS"] = "ignore"
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
|
@ -19,6 +20,7 @@ if str(backend_dir) not in sys.path:
|
|||
sys.path.insert(0, str(backend_dir))
|
||||
|
||||
from loggers import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
|
|
@ -38,9 +40,9 @@ def _resolve_external_ip() -> str:
|
|||
try:
|
||||
req = urllib.request.Request(
|
||||
"http://metadata.google.internal/computeMetadata/v1/instance/network-interfaces/0/access-configs/0/external-ip",
|
||||
headers={"Metadata-Flavor": "Google"},
|
||||
headers = {"Metadata-Flavor": "Google"},
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=1) as resp:
|
||||
with urllib.request.urlopen(req, timeout = 1) as resp:
|
||||
ip = resp.read().decode().strip()
|
||||
if ip:
|
||||
return ip
|
||||
|
|
@ -49,7 +51,7 @@ def _resolve_external_ip() -> str:
|
|||
|
||||
# 2. Try public IP service
|
||||
try:
|
||||
with urllib.request.urlopen("https://ifconfig.me", timeout=3) as resp:
|
||||
with urllib.request.urlopen("https://ifconfig.me", timeout = 3) as resp:
|
||||
ip = resp.read().decode().strip()
|
||||
if ip:
|
||||
return ip
|
||||
|
|
@ -70,7 +72,7 @@ def _resolve_external_ip() -> str:
|
|||
def run_server(
|
||||
host: str = "0.0.0.0",
|
||||
port: int = 8000,
|
||||
frontend_path: Path = "studio/frontend/dist",
|
||||
frontend_path: Path = Path(__file__).resolve().parent.parent / "frontend" / "dist",
|
||||
silent: bool = False,
|
||||
):
|
||||
"""
|
||||
|
|
@ -108,11 +110,13 @@ def run_server(
|
|||
|
||||
# Run server
|
||||
def _run():
|
||||
config = uvicorn.Config(app, host=host, port=port, log_level="info", access_log=False)
|
||||
config = uvicorn.Config(
|
||||
app, host = host, port = port, log_level = "info", access_log = False
|
||||
)
|
||||
server = uvicorn.Server(config)
|
||||
asyncio.run(server.serve())
|
||||
|
||||
thread = Thread(target=_run, daemon=True)
|
||||
thread = Thread(target = _run, daemon = True)
|
||||
thread.start()
|
||||
time.sleep(3)
|
||||
|
||||
|
|
@ -135,24 +139,26 @@ def run_server(
|
|||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Run Unsloth UI Backend server")
|
||||
parser.add_argument("--host", default="0.0.0.0", help="Host to bind to")
|
||||
parser.add_argument("--port", type=int, default=8000, help="Port to bind to")
|
||||
parser = argparse.ArgumentParser(description = "Run Unsloth UI Backend server")
|
||||
parser.add_argument("--host", default = "0.0.0.0", help = "Host to bind to")
|
||||
parser.add_argument("--port", type = int, default = 8000, help = "Port to bind to")
|
||||
parser.add_argument(
|
||||
"--frontend", type=str, default="studio/frontend/dist", help="Path to frontend build"
|
||||
"--frontend",
|
||||
type = str,
|
||||
default = Path(__file__).resolve().parent.parent / "frontend" / "dist",
|
||||
help = "Path to frontend build",
|
||||
)
|
||||
parser.add_argument("--silent", action="store_true", help="Suppress output")
|
||||
parser.add_argument("--silent", action = "store_true", help = "Suppress output")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
frontend_path = Path(args.frontend) if args.frontend else None
|
||||
run_server(
|
||||
host=args.host, port=args.port, frontend_path=frontend_path, silent=args.silent
|
||||
)
|
||||
kwargs = dict(host = args.host, port = args.port, silent = args.silent)
|
||||
if args.frontend is not None:
|
||||
kwargs["frontend_path"] = Path(args.frontend)
|
||||
run_server(**kwargs)
|
||||
|
||||
# Keep running
|
||||
import time
|
||||
|
||||
while True:
|
||||
time.sleep(1)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,2 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
|
@ -6,6 +6,7 @@ Shared pytest configuration for the backend test suite.
|
|||
Ensures that the backend root is on sys.path so that
|
||||
`import utils.utils` (and similar flat imports) resolve correctly.
|
||||
"""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ Run with:
|
|||
cd studio/backend
|
||||
python -m pytest tests/test_utils.py -v
|
||||
"""
|
||||
|
||||
import platform
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
|
|
@ -24,18 +25,20 @@ import pytest
|
|||
# --- Conditional framework imports ---
|
||||
try:
|
||||
import torch
|
||||
|
||||
HAS_TORCH = True
|
||||
except ImportError:
|
||||
HAS_TORCH = False
|
||||
|
||||
try:
|
||||
import mlx.core as mx
|
||||
|
||||
HAS_MLX = True
|
||||
except ImportError:
|
||||
HAS_MLX = False
|
||||
|
||||
needs_torch = pytest.mark.skipif(not HAS_TORCH, reason="PyTorch not installed")
|
||||
needs_mlx = pytest.mark.skipif(not HAS_MLX, reason="MLX not installed")
|
||||
needs_torch = pytest.mark.skipif(not HAS_TORCH, reason = "PyTorch not installed")
|
||||
needs_mlx = pytest.mark.skipif(not HAS_MLX, reason = "MLX not installed")
|
||||
|
||||
from utils.hardware import (
|
||||
get_device,
|
||||
|
|
@ -52,6 +55,7 @@ from utils.utils import format_error_message
|
|||
|
||||
# ========== Helpers ==========
|
||||
|
||||
|
||||
def _actual_device() -> str:
|
||||
"""Return the real device string for the current machine."""
|
||||
if HAS_TORCH and torch.cuda.is_available():
|
||||
|
|
@ -69,6 +73,7 @@ def _reset_and_detect():
|
|||
|
||||
# ========== get_device() ==========
|
||||
|
||||
|
||||
class TestGetDevice:
|
||||
"""Tests for get_device() — should agree with the real hardware."""
|
||||
|
||||
|
|
@ -89,28 +94,34 @@ class TestGetDevice:
|
|||
|
||||
@needs_torch
|
||||
def test_returns_cuda_when_cuda_available(self):
|
||||
with patch("utils.hardware.hardware._has_torch", return_value=True), \
|
||||
patch("torch.cuda.is_available", return_value=True):
|
||||
with (
|
||||
patch("utils.hardware.hardware._has_torch", return_value = True),
|
||||
patch("torch.cuda.is_available", return_value = True),
|
||||
):
|
||||
assert _reset_and_detect() == DeviceType.CUDA
|
||||
|
||||
@needs_mlx
|
||||
def test_returns_mlx_when_on_apple_silicon_with_mlx(self):
|
||||
with patch("utils.hardware.hardware._has_torch", return_value=False), \
|
||||
patch("utils.hardware.hardware.is_apple_silicon", return_value=True), \
|
||||
patch("utils.hardware.hardware._has_mlx", return_value=True):
|
||||
with (
|
||||
patch("utils.hardware.hardware._has_torch", return_value = False),
|
||||
patch("utils.hardware.hardware.is_apple_silicon", return_value = True),
|
||||
patch("utils.hardware.hardware._has_mlx", return_value = True),
|
||||
):
|
||||
assert _reset_and_detect() == DeviceType.MLX
|
||||
|
||||
def test_returns_cpu_when_nothing_available(self):
|
||||
with patch("utils.hardware.hardware._has_torch", return_value=False), \
|
||||
patch("utils.hardware.hardware.is_apple_silicon", return_value=False), \
|
||||
patch("utils.hardware.hardware._has_mlx", return_value=False):
|
||||
with (
|
||||
patch("utils.hardware.hardware._has_torch", return_value = False),
|
||||
patch("utils.hardware.hardware.is_apple_silicon", return_value = False),
|
||||
patch("utils.hardware.hardware._has_mlx", return_value = False),
|
||||
):
|
||||
assert _reset_and_detect() == DeviceType.CPU
|
||||
|
||||
|
||||
# ========== is_apple_silicon() ==========
|
||||
|
||||
class TestIsAppleSilicon:
|
||||
|
||||
class TestIsAppleSilicon:
|
||||
def test_returns_bool(self):
|
||||
assert isinstance(is_apple_silicon(), bool)
|
||||
|
||||
|
|
@ -136,6 +147,7 @@ class TestIsAppleSilicon:
|
|||
|
||||
# ========== clear_gpu_cache() ==========
|
||||
|
||||
|
||||
class TestClearGpuCache:
|
||||
"""clear_gpu_cache() must never raise, regardless of platform."""
|
||||
|
||||
|
|
@ -144,9 +156,11 @@ class TestClearGpuCache:
|
|||
|
||||
@needs_torch
|
||||
def test_calls_cuda_cache_when_cuda(self):
|
||||
with patch("utils.hardware.hardware.get_device", return_value=DeviceType.CUDA), \
|
||||
patch("torch.cuda.empty_cache") as mock_empty, \
|
||||
patch("torch.cuda.ipc_collect") as mock_ipc:
|
||||
with (
|
||||
patch("utils.hardware.hardware.get_device", return_value = DeviceType.CUDA),
|
||||
patch("torch.cuda.empty_cache") as mock_empty,
|
||||
patch("torch.cuda.ipc_collect") as mock_ipc,
|
||||
):
|
||||
clear_gpu_cache()
|
||||
mock_empty.assert_called_once()
|
||||
mock_ipc.assert_called_once()
|
||||
|
|
@ -154,18 +168,18 @@ class TestClearGpuCache:
|
|||
@needs_mlx
|
||||
def test_mlx_does_not_raise(self):
|
||||
"""MLX cache clear is a no-op — should just succeed."""
|
||||
with patch("utils.hardware.hardware.get_device", return_value=DeviceType.MLX):
|
||||
with patch("utils.hardware.hardware.get_device", return_value = DeviceType.MLX):
|
||||
clear_gpu_cache()
|
||||
|
||||
def test_noop_on_cpu(self):
|
||||
with patch("utils.hardware.hardware.get_device", return_value=DeviceType.CPU):
|
||||
with patch("utils.hardware.hardware.get_device", return_value = DeviceType.CPU):
|
||||
clear_gpu_cache()
|
||||
|
||||
|
||||
# ========== get_gpu_memory_info() ==========
|
||||
|
||||
class TestGetGpuMemoryInfo:
|
||||
|
||||
class TestGetGpuMemoryInfo:
|
||||
def test_returns_dict(self):
|
||||
result = get_gpu_memory_info()
|
||||
assert isinstance(result, dict)
|
||||
|
|
@ -183,8 +197,7 @@ class TestGetGpuMemoryInfo:
|
|||
# --- When a GPU IS available ---
|
||||
|
||||
@pytest.mark.skipif(
|
||||
_actual_device() == "cpu",
|
||||
reason="No GPU available on this machine"
|
||||
_actual_device() == "cpu", reason = "No GPU available on this machine"
|
||||
)
|
||||
def test_gpu_available_fields(self):
|
||||
result = get_gpu_memory_info()
|
||||
|
|
@ -200,14 +213,16 @@ class TestGetGpuMemoryInfo:
|
|||
@needs_torch
|
||||
def test_cuda_path_returns_correct_fields(self):
|
||||
mock_props = MagicMock()
|
||||
mock_props.total_memory = 16 * (1024 ** 3)
|
||||
mock_props.total_memory = 16 * (1024**3)
|
||||
mock_props.name = "NVIDIA Test GPU"
|
||||
|
||||
with patch("utils.hardware.hardware.get_device", return_value=DeviceType.CUDA), \
|
||||
patch("torch.cuda.current_device", return_value=0), \
|
||||
patch("torch.cuda.get_device_properties", return_value=mock_props), \
|
||||
patch("torch.cuda.memory_allocated", return_value=4 * (1024 ** 3)), \
|
||||
patch("torch.cuda.memory_reserved", return_value=6 * (1024 ** 3)):
|
||||
with (
|
||||
patch("utils.hardware.hardware.get_device", return_value = DeviceType.CUDA),
|
||||
patch("torch.cuda.current_device", return_value = 0),
|
||||
patch("torch.cuda.get_device_properties", return_value = mock_props),
|
||||
patch("torch.cuda.memory_allocated", return_value = 4 * (1024**3)),
|
||||
patch("torch.cuda.memory_reserved", return_value = 6 * (1024**3)),
|
||||
):
|
||||
result = get_gpu_memory_info()
|
||||
|
||||
assert result["available"] is True
|
||||
|
|
@ -223,13 +238,15 @@ class TestGetGpuMemoryInfo:
|
|||
@needs_mlx
|
||||
def test_mlx_path_returns_correct_fields(self):
|
||||
mock_psutil_mem = MagicMock()
|
||||
mock_psutil_mem.total = 32 * (1024 ** 3) # 32 GB unified
|
||||
mock_psutil_mem.total = 32 * (1024**3) # 32 GB unified
|
||||
|
||||
mock_psutil = MagicMock()
|
||||
mock_psutil.virtual_memory.return_value = mock_psutil_mem
|
||||
|
||||
with patch("utils.hardware.hardware.get_device", return_value=DeviceType.MLX), \
|
||||
patch.dict("sys.modules", {"psutil": mock_psutil}):
|
||||
with (
|
||||
patch("utils.hardware.hardware.get_device", return_value = DeviceType.MLX),
|
||||
patch.dict("sys.modules", {"psutil": mock_psutil}),
|
||||
):
|
||||
result = get_gpu_memory_info()
|
||||
|
||||
assert result["available"] is True
|
||||
|
|
@ -240,7 +257,7 @@ class TestGetGpuMemoryInfo:
|
|||
# --- CPU-only path ---
|
||||
|
||||
def test_cpu_path_returns_unavailable(self):
|
||||
with patch("utils.hardware.hardware.get_device", return_value=DeviceType.CPU):
|
||||
with patch("utils.hardware.hardware.get_device", return_value = DeviceType.CPU):
|
||||
result = get_gpu_memory_info()
|
||||
assert result["available"] is False
|
||||
assert result["backend"] == "cpu"
|
||||
|
|
@ -249,8 +266,13 @@ class TestGetGpuMemoryInfo:
|
|||
|
||||
@needs_torch
|
||||
def test_cuda_error_returns_unavailable(self):
|
||||
with patch("utils.hardware.hardware.get_device", return_value=DeviceType.CUDA), \
|
||||
patch("torch.cuda.current_device", side_effect=RuntimeError("CUDA init failed")):
|
||||
with (
|
||||
patch("utils.hardware.hardware.get_device", return_value = DeviceType.CUDA),
|
||||
patch(
|
||||
"torch.cuda.current_device",
|
||||
side_effect = RuntimeError("CUDA init failed"),
|
||||
),
|
||||
):
|
||||
result = get_gpu_memory_info()
|
||||
assert result["available"] is False
|
||||
assert "error" in result
|
||||
|
|
@ -258,8 +280,8 @@ class TestGetGpuMemoryInfo:
|
|||
|
||||
# ========== log_gpu_memory() ==========
|
||||
|
||||
class TestLogGpuMemory:
|
||||
|
||||
class TestLogGpuMemory:
|
||||
def test_does_not_raise(self):
|
||||
log_gpu_memory("test")
|
||||
|
||||
|
|
@ -275,8 +297,13 @@ class TestLogGpuMemory:
|
|||
}
|
||||
import structlog
|
||||
from loggers import get_logger
|
||||
with patch("utils.hardware.hardware.get_gpu_memory_info", return_value=fake_info), \
|
||||
caplog.at_level(logging.INFO, logger="utils.hardware.hardware"):
|
||||
|
||||
with (
|
||||
patch(
|
||||
"utils.hardware.hardware.get_gpu_memory_info", return_value = fake_info
|
||||
),
|
||||
caplog.at_level(logging.INFO, logger = "utils.hardware.hardware"),
|
||||
):
|
||||
log_gpu_memory("unit-test")
|
||||
|
||||
assert "unit-test" in caplog.text
|
||||
|
|
@ -287,8 +314,13 @@ class TestLogGpuMemory:
|
|||
fake_info = {"available": False, "backend": "cpu"}
|
||||
import structlog
|
||||
from loggers import get_logger
|
||||
with patch("utils.hardware.hardware.get_gpu_memory_info", return_value=fake_info), \
|
||||
caplog.at_level(logging.INFO, logger="utils.hardware.hardware"):
|
||||
|
||||
with (
|
||||
patch(
|
||||
"utils.hardware.hardware.get_gpu_memory_info", return_value = fake_info
|
||||
),
|
||||
caplog.at_level(logging.INFO, logger = "utils.hardware.hardware"),
|
||||
):
|
||||
log_gpu_memory("cpu-test")
|
||||
|
||||
assert "No GPU available" in caplog.text
|
||||
|
|
@ -296,8 +328,8 @@ class TestLogGpuMemory:
|
|||
|
||||
# ========== format_error_message() ==========
|
||||
|
||||
class TestFormatErrorMessage:
|
||||
|
||||
class TestFormatErrorMessage:
|
||||
def test_not_found(self):
|
||||
err = Exception("Repository not found for unsloth/test")
|
||||
msg = format_error_message(err, "unsloth/test")
|
||||
|
|
@ -324,7 +356,7 @@ class TestFormatErrorMessage:
|
|||
@needs_torch
|
||||
def test_cuda_oom(self):
|
||||
err = Exception("CUDA out of memory")
|
||||
with patch("utils.hardware.get_device", return_value=DeviceType.CUDA):
|
||||
with patch("utils.hardware.get_device", return_value = DeviceType.CUDA):
|
||||
msg = format_error_message(err, "big/model")
|
||||
assert "GPU" in msg
|
||||
assert "big/model" not in msg
|
||||
|
|
@ -335,7 +367,7 @@ class TestFormatErrorMessage:
|
|||
@needs_mlx
|
||||
def test_mlx_oom(self):
|
||||
err = Exception("MLX backend out of memory")
|
||||
with patch("utils.hardware.get_device", return_value=DeviceType.MLX):
|
||||
with patch("utils.hardware.get_device", return_value = DeviceType.MLX):
|
||||
msg = format_error_message(err, "unsloth/huge-model")
|
||||
assert "Apple Silicon" in msg
|
||||
|
||||
|
|
@ -343,7 +375,7 @@ class TestFormatErrorMessage:
|
|||
|
||||
def test_cpu_oom(self):
|
||||
err = Exception("not enough memory to allocate")
|
||||
with patch("utils.hardware.get_device", return_value=DeviceType.CPU):
|
||||
with patch("utils.hardware.get_device", return_value = DeviceType.CPU):
|
||||
msg = format_error_message(err, "any/model")
|
||||
assert "system" in msg.lower()
|
||||
|
||||
|
|
|
|||
2
studio/backend/utils/__init__.py
Normal file
2
studio/backend/utils/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
||||
|
|
@ -8,6 +8,7 @@ The unsloth_compiled_cache is created by unsloth_zoo/compiler.py during
|
|||
FastModel.from_pretrained() and contains model-type-specific compiled Python
|
||||
files. It should be cleared between model loads to avoid stale artefacts.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import structlog
|
||||
from loggers import get_logger
|
||||
|
|
@ -16,8 +17,8 @@ from pathlib import Path
|
|||
logger = get_logger(__name__)
|
||||
|
||||
# Possible locations where unsloth_compiled_cache may appear
|
||||
_BACKEND_DIR = Path(__file__).resolve().parent.parent # studio/backend
|
||||
_PROJECT_ROOT = _BACKEND_DIR.parent.parent # repo root
|
||||
_BACKEND_DIR = Path(__file__).resolve().parent.parent # studio/backend
|
||||
_PROJECT_ROOT = _BACKEND_DIR.parent.parent # repo root
|
||||
|
||||
_CACHE_DIRS = [
|
||||
_BACKEND_DIR / "unsloth_compiled_cache",
|
||||
|
|
@ -31,4 +32,4 @@ def clear_unsloth_compiled_cache() -> None:
|
|||
for cache_dir in _CACHE_DIRS:
|
||||
if cache_dir.exists():
|
||||
logger.info(f"Removing unsloth compiled cache: {cache_dir}")
|
||||
shutil.rmtree(cache_dir, ignore_errors=True)
|
||||
shutil.rmtree(cache_dir, ignore_errors = True)
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ def get_tokenizer_chat_template(tokenizer, model_name):
|
|||
try:
|
||||
tokenizer = get_chat_template(
|
||||
tokenizer,
|
||||
chat_template=matched_template,
|
||||
chat_template = matched_template,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.info(f"⚠️ Failed to apply Unsloth template '{matched_template}': {e}")
|
||||
|
|
@ -80,7 +80,7 @@ def get_tokenizer_chat_template(tokenizer, model_name):
|
|||
try:
|
||||
tokenizer = get_chat_template(
|
||||
tokenizer,
|
||||
chat_template="chatml",
|
||||
chat_template = "chatml",
|
||||
)
|
||||
except Exception as e:
|
||||
logger.info(f"⚠️ Failed to apply default ChatML template: {e}")
|
||||
|
|
@ -119,14 +119,14 @@ def get_dataset_info_summary(dataset_info):
|
|||
def apply_chat_template_to_dataset(
|
||||
dataset_info,
|
||||
tokenizer,
|
||||
model_name=None,
|
||||
custom_prompt_template=None,
|
||||
add_eos_token=False,
|
||||
remove_bos_prefix=False,
|
||||
custom_format_mapping=None,
|
||||
auto_detect_mapping=True,
|
||||
batch_size=1000,
|
||||
num_proc=None,
|
||||
model_name = None,
|
||||
custom_prompt_template = None,
|
||||
add_eos_token = False,
|
||||
remove_bos_prefix = False,
|
||||
custom_format_mapping = None,
|
||||
auto_detect_mapping = True,
|
||||
batch_size = 1000,
|
||||
num_proc = None,
|
||||
):
|
||||
"""
|
||||
Applies chat template to dataset based on its format.
|
||||
|
|
@ -233,7 +233,7 @@ def apply_chat_template_to_dataset(
|
|||
return result
|
||||
|
||||
try:
|
||||
dataset = dataset.map(_apply_custom_mapping, batched=True, batch_size=batch_size)
|
||||
dataset = dataset.map(_apply_custom_mapping, batched = True, batch_size = batch_size)
|
||||
# Update to use conversations format
|
||||
final_format = "chatml_conversations"
|
||||
chat_column = "conversations"
|
||||
|
|
@ -256,7 +256,7 @@ def apply_chat_template_to_dataset(
|
|||
if not (hasattr(tokenizer, 'chat_template') and tokenizer.chat_template):
|
||||
try:
|
||||
from unsloth.chat_templates import get_chat_template
|
||||
tokenizer = get_chat_template(tokenizer, chat_template="alpaca")
|
||||
tokenizer = get_chat_template(tokenizer, chat_template = "alpaca")
|
||||
logger.info(f"📝 Set alpaca chat template on tokenizer for model saving")
|
||||
except Exception as e:
|
||||
logger.info(f"⚠️ Could not set alpaca template on tokenizer: {e}")
|
||||
|
|
@ -333,8 +333,8 @@ def apply_chat_template_to_dataset(
|
|||
try:
|
||||
text = tokenizer.apply_chat_template(
|
||||
convo,
|
||||
tokenize=False,
|
||||
add_generation_prompt=False
|
||||
tokenize = False,
|
||||
add_generation_prompt = False
|
||||
)
|
||||
|
||||
if remove_bos_prefix:
|
||||
|
|
|
|||
|
|
@ -12,11 +12,10 @@ import torch
|
|||
from dataclasses import dataclass
|
||||
from typing import Any, List, Optional, Union
|
||||
from loggers import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
|
||||
|
||||
@dataclass
|
||||
class DataCollatorSpeechSeq2SeqWithPadding:
|
||||
"""
|
||||
|
|
@ -26,16 +25,23 @@ class DataCollatorSpeechSeq2SeqWithPadding:
|
|||
masks padding in labels with -100, and strips leading BOS token.
|
||||
Mirrors the collator from the Whisper.ipynb notebook.
|
||||
"""
|
||||
|
||||
processor: Any
|
||||
|
||||
def __call__(self, features: List[dict]) -> dict:
|
||||
input_features = [{"input_features": feature["input_features"]} for feature in features]
|
||||
batch = self.processor.feature_extractor.pad(input_features, return_tensors="pt")
|
||||
input_features = [
|
||||
{"input_features": feature["input_features"]} for feature in features
|
||||
]
|
||||
batch = self.processor.feature_extractor.pad(
|
||||
input_features, return_tensors = "pt"
|
||||
)
|
||||
|
||||
label_features = [{"input_ids": feature["labels"]} for feature in features]
|
||||
labels_batch = self.processor.tokenizer.pad(label_features, return_tensors="pt")
|
||||
labels_batch = self.processor.tokenizer.pad(label_features, return_tensors = "pt")
|
||||
|
||||
labels = labels_batch["input_ids"].masked_fill(labels_batch.attention_mask.ne(1), -100)
|
||||
labels = labels_batch["input_ids"].masked_fill(
|
||||
labels_batch.attention_mask.ne(1), -100
|
||||
)
|
||||
|
||||
if (labels[:, 0] == self.processor.tokenizer.bos_token_id).all().cpu().item():
|
||||
labels = labels[:, 1:]
|
||||
|
|
@ -54,6 +60,7 @@ class DeepSeekOCRDataCollator:
|
|||
- Text tokenization
|
||||
- Proper label masking for instruction fine-tuning
|
||||
"""
|
||||
|
||||
processor: Any # Qwen2VLProcessor or similar
|
||||
max_length: int = 2048
|
||||
ignore_index: int = -100
|
||||
|
|
@ -86,7 +93,7 @@ class DeepSeekOCRDataCollator:
|
|||
for item in content:
|
||||
if isinstance(item, dict) and item.get("type") == "image":
|
||||
img = item.get("image")
|
||||
if img is not None and hasattr(img, 'size'): # PIL Image
|
||||
if img is not None and hasattr(img, "size"): # PIL Image
|
||||
all_images.append(img)
|
||||
|
||||
# Process with the VL processor
|
||||
|
|
@ -94,19 +101,19 @@ class DeepSeekOCRDataCollator:
|
|||
# Qwen2VL style processing
|
||||
texts = [
|
||||
self.processor.apply_chat_template(
|
||||
msgs, tokenize=False, add_generation_prompt=False
|
||||
msgs, tokenize = False, add_generation_prompt = False
|
||||
)
|
||||
for msgs in all_messages
|
||||
]
|
||||
|
||||
# Process with images
|
||||
inputs = self.processor(
|
||||
text=texts,
|
||||
images=all_images if all_images else None,
|
||||
return_tensors="pt",
|
||||
padding=True,
|
||||
truncation=True,
|
||||
max_length=self.max_length,
|
||||
text = texts,
|
||||
images = all_images if all_images else None,
|
||||
return_tensors = "pt",
|
||||
padding = True,
|
||||
truncation = True,
|
||||
max_length = self.max_length,
|
||||
)
|
||||
|
||||
# Create labels (mask input, keep output)
|
||||
|
|
@ -134,6 +141,7 @@ class VLMDataCollator:
|
|||
- LLaVA
|
||||
- Other VL models with compatible processors
|
||||
"""
|
||||
|
||||
processor: Any
|
||||
max_length: int = 2048
|
||||
ignore_index: int = -100
|
||||
|
|
@ -163,26 +171,26 @@ class VLMDataCollator:
|
|||
# Apply chat template
|
||||
texts = [
|
||||
self.processor.apply_chat_template(
|
||||
msgs, tokenize=False, add_generation_prompt=False
|
||||
msgs, tokenize = False, add_generation_prompt = False
|
||||
)
|
||||
for msgs in all_messages
|
||||
]
|
||||
|
||||
# Process inputs
|
||||
inputs = self.processor(
|
||||
text=texts,
|
||||
images=all_images if all_images else None,
|
||||
return_tensors="pt",
|
||||
padding=True,
|
||||
truncation=True,
|
||||
max_length=self.max_length,
|
||||
text = texts,
|
||||
images = all_images if all_images else None,
|
||||
return_tensors = "pt",
|
||||
padding = True,
|
||||
truncation = True,
|
||||
max_length = self.max_length,
|
||||
)
|
||||
|
||||
# Create labels
|
||||
labels = inputs["input_ids"].clone()
|
||||
|
||||
# Mask padding
|
||||
if hasattr(self.processor, 'tokenizer'):
|
||||
if hasattr(self.processor, "tokenizer"):
|
||||
pad_token_id = self.processor.tokenizer.pad_token_id
|
||||
else:
|
||||
pad_token_id = self.processor.pad_token_id
|
||||
|
|
|
|||
|
|
@ -45,22 +45,21 @@ from .vlm_processing import generate_smart_vlm_instruction
|
|||
from .data_collators import DeepSeekOCRDataCollator, VLMDataCollator
|
||||
from .model_mappings import TEMPLATE_TO_MODEL_MAPPER
|
||||
from loggers import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
|
||||
|
||||
def check_dataset_format(dataset, is_vlm: bool = False) -> dict:
|
||||
"""
|
||||
Lightweight format check without processing - for frontend validation.
|
||||
|
||||
|
||||
Use this to quickly determine if user needs to manually map columns
|
||||
before calling the full format_and_template_dataset().
|
||||
|
||||
|
||||
Args:
|
||||
dataset: HuggingFace dataset
|
||||
is_vlm: Whether this is a Vision-Language Model dataset
|
||||
|
||||
|
||||
Returns:
|
||||
dict: {
|
||||
"requires_manual_mapping": bool - True if user must map columns,
|
||||
|
|
@ -71,8 +70,12 @@ def check_dataset_format(dataset, is_vlm: bool = False) -> dict:
|
|||
"detected_text_column": str or None - For VLM only,
|
||||
}
|
||||
"""
|
||||
columns = list(dataset.column_names) if hasattr(dataset, 'column_names') else list(next(iter(dataset)).keys())
|
||||
|
||||
columns = (
|
||||
list(dataset.column_names)
|
||||
if hasattr(dataset, "column_names")
|
||||
else list(next(iter(dataset)).keys())
|
||||
)
|
||||
|
||||
# Auto-detect multimodal data regardless of is_vlm flag
|
||||
multimodal_info = detect_multimodal_dataset(dataset)
|
||||
is_audio = multimodal_info.get("is_audio", False)
|
||||
|
|
@ -185,11 +188,17 @@ def check_dataset_format(dataset, is_vlm: bool = False) -> dict:
|
|||
**audio_fields,
|
||||
}
|
||||
|
||||
|
||||
# Normalise any format-specific role to canonical chatml (user/assistant/system)
|
||||
_TO_CHATML = {
|
||||
"user": "user", "human": "user", "instruction": "user",
|
||||
"assistant": "assistant", "gpt": "assistant", "output": "assistant",
|
||||
"system": "system", "input": "system",
|
||||
"user": "user",
|
||||
"human": "user",
|
||||
"instruction": "user",
|
||||
"assistant": "assistant",
|
||||
"gpt": "assistant",
|
||||
"output": "assistant",
|
||||
"system": "system",
|
||||
"input": "system",
|
||||
}
|
||||
_CHATML_ROLE_ORDER = ("system", "user", "assistant")
|
||||
_CHATML_TO_ALPACA = {"user": "instruction", "system": "input", "assistant": "output"}
|
||||
|
|
@ -232,11 +241,21 @@ def _apply_user_mapping(dataset, mapping: dict, batch_size: int = 1000):
|
|||
for col in role_groups[chatml_role]:
|
||||
if col in examples:
|
||||
content = examples[col][i]
|
||||
convo.append({"role": chatml_role, "content": str(content) if content else ""})
|
||||
convo.append(
|
||||
{
|
||||
"role": chatml_role,
|
||||
"content": str(content) if content else "",
|
||||
}
|
||||
)
|
||||
conversations.append(convo)
|
||||
return {"conversations": conversations}
|
||||
|
||||
return dataset.map(_convert, batched=True, batch_size=batch_size, remove_columns=dataset.column_names)
|
||||
return dataset.map(
|
||||
_convert,
|
||||
batched = True,
|
||||
batch_size = batch_size,
|
||||
remove_columns = dataset.column_names,
|
||||
)
|
||||
|
||||
|
||||
def _extract_column_value(val, col: str, label_mapping: dict) -> str:
|
||||
|
|
@ -248,7 +267,7 @@ def _extract_column_value(val, col: str, label_mapping: dict) -> str:
|
|||
inner = val["text"]
|
||||
str_val = inner[0] if isinstance(inner, list) and inner else str(inner)
|
||||
else:
|
||||
str_val = json.dumps(val, ensure_ascii=False)
|
||||
str_val = json.dumps(val, ensure_ascii = False)
|
||||
elif isinstance(val, list):
|
||||
str_val = val[0] if len(val) == 1 else ", ".join(str(v) for v in val)
|
||||
else:
|
||||
|
|
@ -286,6 +305,7 @@ def _apply_template_mapping(
|
|||
role_groups[canonical].append(col)
|
||||
|
||||
import logging as _log
|
||||
|
||||
_log.getLogger(__name__).info(
|
||||
f"Applying role mapping: sys={bool(system_prompt)}, "
|
||||
f"user_cols={role_groups['user']}, asst_cols={role_groups['assistant']}, "
|
||||
|
|
@ -326,8 +346,10 @@ def _apply_template_mapping(
|
|||
return {"conversations": conversations}
|
||||
|
||||
return dataset.map(
|
||||
_convert, batched=True, batch_size=batch_size,
|
||||
remove_columns=dataset.column_names,
|
||||
_convert,
|
||||
batched = True,
|
||||
batch_size = batch_size,
|
||||
remove_columns = dataset.column_names,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -341,7 +363,11 @@ def _apply_user_mapping_alpaca(dataset, mapping: dict, batch_size: int = 1000):
|
|||
Returns:
|
||||
Dataset with instruction/input/output columns
|
||||
"""
|
||||
col_for: dict[str, str | None] = {"instruction": None, "input": None, "output": None}
|
||||
col_for: dict[str, str | None] = {
|
||||
"instruction": None,
|
||||
"input": None,
|
||||
"output": None,
|
||||
}
|
||||
for col_name, role in mapping.items():
|
||||
canonical = _TO_CHATML.get(role)
|
||||
alpaca_field = _CHATML_TO_ALPACA.get(canonical) if canonical else None
|
||||
|
|
@ -352,25 +378,48 @@ def _apply_user_mapping_alpaca(dataset, mapping: dict, batch_size: int = 1000):
|
|||
num = len(next(iter(examples.values())))
|
||||
instructions, inputs, outputs = [], [], []
|
||||
for i in range(num):
|
||||
for field, dest in (("instruction", instructions), ("input", inputs), ("output", outputs)):
|
||||
for field, dest in (
|
||||
("instruction", instructions),
|
||||
("input", inputs),
|
||||
("output", outputs),
|
||||
):
|
||||
col = col_for[field]
|
||||
val = str(examples[col][i]) if col and col in examples and examples[col][i] else ""
|
||||
val = (
|
||||
str(examples[col][i])
|
||||
if col and col in examples and examples[col][i]
|
||||
else ""
|
||||
)
|
||||
dest.append(val)
|
||||
return {"instruction": instructions, "input": inputs, "output": outputs}
|
||||
|
||||
return dataset.map(_convert, batched=True, batch_size=batch_size, remove_columns=dataset.column_names)
|
||||
return dataset.map(
|
||||
_convert,
|
||||
batched = True,
|
||||
batch_size = batch_size,
|
||||
remove_columns = dataset.column_names,
|
||||
)
|
||||
|
||||
|
||||
def format_dataset(
|
||||
dataset,
|
||||
format_type = "auto",
|
||||
tokenizer = None,
|
||||
aliases_for_system = ["system",],
|
||||
aliases_for_user = ["user", "human", "input",],
|
||||
aliases_for_assistant = ["gpt", "assistant", "output",],
|
||||
batch_size = 1000,
|
||||
num_proc = None,
|
||||
auto_detect_custom = True,
|
||||
format_type = "auto",
|
||||
tokenizer = None,
|
||||
aliases_for_system = [
|
||||
"system",
|
||||
],
|
||||
aliases_for_user = [
|
||||
"user",
|
||||
"human",
|
||||
"input",
|
||||
],
|
||||
aliases_for_assistant = [
|
||||
"gpt",
|
||||
"assistant",
|
||||
"output",
|
||||
],
|
||||
batch_size = 1000,
|
||||
num_proc = None,
|
||||
auto_detect_custom = True,
|
||||
custom_format_mapping = None,
|
||||
):
|
||||
"""
|
||||
|
|
@ -395,13 +444,17 @@ def format_dataset(
|
|||
if custom_format_mapping:
|
||||
try:
|
||||
if format_type == "alpaca":
|
||||
mapped_dataset = _apply_user_mapping_alpaca(dataset, custom_format_mapping, batch_size)
|
||||
mapped_dataset = _apply_user_mapping_alpaca(
|
||||
dataset, custom_format_mapping, batch_size
|
||||
)
|
||||
final_format = "alpaca"
|
||||
chat_column = None
|
||||
else:
|
||||
# auto / chatml / sharegpt / conversational — all produce chatml conversations
|
||||
# (sharegpt is always standardized to role/content internally)
|
||||
mapped_dataset = _apply_user_mapping(dataset, custom_format_mapping, batch_size)
|
||||
mapped_dataset = _apply_user_mapping(
|
||||
dataset, custom_format_mapping, batch_size
|
||||
)
|
||||
final_format = "chatml_conversations"
|
||||
chat_column = "conversations"
|
||||
|
||||
|
|
@ -414,7 +467,9 @@ def format_dataset(
|
|||
"requires_manual_mapping": False,
|
||||
"is_image": multimodal_info["is_image"],
|
||||
"multimodal_info": multimodal_info,
|
||||
"warnings": [f"Applied user-provided column mapping ({format_type}): {custom_format_mapping}"]
|
||||
"warnings": [
|
||||
f"Applied user-provided column mapping ({format_type}): {custom_format_mapping}"
|
||||
],
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
|
|
@ -426,10 +481,9 @@ def format_dataset(
|
|||
"requires_manual_mapping": True,
|
||||
"is_image": multimodal_info["is_image"],
|
||||
"multimodal_info": multimodal_info,
|
||||
"warnings": [f"Failed to apply user mapping: {e}"]
|
||||
"warnings": [f"Failed to apply user mapping: {e}"],
|
||||
}
|
||||
|
||||
|
||||
# Detect current format
|
||||
detected = detect_dataset_format(dataset)
|
||||
warnings = []
|
||||
|
|
@ -442,7 +496,6 @@ def format_dataset(
|
|||
|
||||
# AUTO MODE: Keep format but standardize if needed
|
||||
if format_type == "auto":
|
||||
|
||||
# Alpaca - keep as is
|
||||
if detected["format"] == "alpaca":
|
||||
return {
|
||||
|
|
@ -454,16 +507,20 @@ def format_dataset(
|
|||
"requires_manual_mapping": False,
|
||||
"is_image": multimodal_info["is_image"],
|
||||
"multimodal_info": multimodal_info,
|
||||
"warnings": []
|
||||
"warnings": [],
|
||||
}
|
||||
|
||||
# ShareGPT - needs standardization
|
||||
elif detected["format"] == "sharegpt":
|
||||
try:
|
||||
standardized = standardize_chat_format(
|
||||
dataset, tokenizer, aliases_for_system,
|
||||
aliases_for_user, aliases_for_assistant,
|
||||
batch_size, num_proc
|
||||
dataset,
|
||||
tokenizer,
|
||||
aliases_for_system,
|
||||
aliases_for_user,
|
||||
aliases_for_assistant,
|
||||
batch_size,
|
||||
num_proc,
|
||||
)
|
||||
return {
|
||||
"dataset": standardized,
|
||||
|
|
@ -474,7 +531,7 @@ def format_dataset(
|
|||
"requires_manual_mapping": False,
|
||||
"is_image": multimodal_info["is_image"],
|
||||
"multimodal_info": multimodal_info,
|
||||
"warnings": []
|
||||
"warnings": [],
|
||||
}
|
||||
except Exception as e:
|
||||
warnings.append(f"Failed to standardize ShareGPT format: {e}")
|
||||
|
|
@ -487,10 +544,14 @@ def format_dataset(
|
|||
"requires_manual_mapping": True,
|
||||
"is_image": multimodal_info["is_image"],
|
||||
"multimodal_info": multimodal_info,
|
||||
"warnings": warnings
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
elif detected["format"] == "chatml" and detected["chat_column"] in ["conversations", "messages", "texts"]:
|
||||
elif detected["format"] == "chatml" and detected["chat_column"] in [
|
||||
"conversations",
|
||||
"messages",
|
||||
"texts",
|
||||
]:
|
||||
return {
|
||||
"dataset": dataset,
|
||||
"detected_format": f"chatml_{detected['chat_column']}",
|
||||
|
|
@ -500,13 +561,14 @@ def format_dataset(
|
|||
"requires_manual_mapping": False,
|
||||
"is_image": multimodal_info["is_image"],
|
||||
"multimodal_info": multimodal_info,
|
||||
"warnings": warnings
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
|
||||
# Unknown - try standardization, if fails pass as is
|
||||
else:
|
||||
warnings.append(f"Unknown format detected. Keys found: {detected['sample_keys']}")
|
||||
warnings.append(
|
||||
f"Unknown format detected. Keys found: {detected['sample_keys']}"
|
||||
)
|
||||
|
||||
# NEW: Try heuristic detection
|
||||
if auto_detect_custom:
|
||||
|
|
@ -514,7 +576,6 @@ def format_dataset(
|
|||
if custom_mapping:
|
||||
warnings.append(f"Auto-detected column mapping: {custom_mapping}")
|
||||
|
||||
|
||||
def _apply_auto_mapping(examples):
|
||||
conversations = []
|
||||
num_examples = len(examples[list(examples.keys())[0]])
|
||||
|
|
@ -523,25 +584,27 @@ def format_dataset(
|
|||
all_columns = set(examples.keys())
|
||||
mapped_columns = set(custom_mapping.keys())
|
||||
preserved_columns = {
|
||||
col: examples[col]
|
||||
for col in all_columns - mapped_columns
|
||||
col: examples[col] for col in all_columns - mapped_columns
|
||||
}
|
||||
|
||||
for i in range(num_examples):
|
||||
convo = []
|
||||
for target_role in ['system', 'user', 'assistant']:
|
||||
for target_role in ["system", "user", "assistant"]:
|
||||
for col_name, role in custom_mapping.items():
|
||||
if role == target_role and col_name in examples:
|
||||
content = examples[col_name][i]
|
||||
if content and str(content).strip():
|
||||
convo.append({"role": role, "content": str(content)})
|
||||
convo.append(
|
||||
{"role": role, "content": str(content)}
|
||||
)
|
||||
conversations.append(convo)
|
||||
|
||||
return {"conversations": conversations, **preserved_columns}
|
||||
|
||||
|
||||
try:
|
||||
dataset = dataset.map(_apply_auto_mapping, batched=True, batch_size=batch_size)
|
||||
dataset = dataset.map(
|
||||
_apply_auto_mapping, batched = True, batch_size = batch_size
|
||||
)
|
||||
return {
|
||||
"dataset": dataset,
|
||||
"detected_format": "unknown",
|
||||
|
|
@ -551,7 +614,7 @@ def format_dataset(
|
|||
"requires_manual_mapping": False,
|
||||
"is_image": multimodal_info["is_image"],
|
||||
"multimodal_info": multimodal_info,
|
||||
"warnings": warnings
|
||||
"warnings": warnings,
|
||||
}
|
||||
except Exception as e:
|
||||
warnings.append(f"Auto-detection failed: {e}")
|
||||
|
|
@ -560,9 +623,13 @@ def format_dataset(
|
|||
if detected["chat_column"]:
|
||||
try:
|
||||
standardized = standardize_chat_format(
|
||||
dataset, tokenizer, aliases_for_system,
|
||||
aliases_for_user, aliases_for_assistant,
|
||||
batch_size, num_proc
|
||||
dataset,
|
||||
tokenizer,
|
||||
aliases_for_system,
|
||||
aliases_for_user,
|
||||
aliases_for_assistant,
|
||||
batch_size,
|
||||
num_proc,
|
||||
)
|
||||
warnings.append("Successfully standardized unknown format")
|
||||
return {
|
||||
|
|
@ -574,10 +641,12 @@ def format_dataset(
|
|||
"requires_manual_mapping": False,
|
||||
"is_image": multimodal_info["is_image"],
|
||||
"multimodal_info": multimodal_info,
|
||||
"warnings": warnings
|
||||
"warnings": warnings,
|
||||
}
|
||||
except Exception as e:
|
||||
warnings.append(f"Could not standardize: {e}. Passing dataset as-is.")
|
||||
warnings.append(
|
||||
f"Could not standardize: {e}. Passing dataset as-is."
|
||||
)
|
||||
|
||||
# Return as-is with warnings
|
||||
return {
|
||||
|
|
@ -589,12 +658,11 @@ def format_dataset(
|
|||
"requires_manual_mapping": True,
|
||||
"is_image": multimodal_info["is_image"],
|
||||
"multimodal_info": multimodal_info,
|
||||
"warnings": warnings
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
# ALPACA MODE: Convert to Alpaca
|
||||
elif format_type == "alpaca":
|
||||
|
||||
if detected["format"] == "alpaca":
|
||||
return {
|
||||
"dataset": dataset,
|
||||
|
|
@ -605,16 +673,20 @@ def format_dataset(
|
|||
"requires_manual_mapping": False,
|
||||
"is_image": multimodal_info["is_image"],
|
||||
"multimodal_info": multimodal_info,
|
||||
"warnings": []
|
||||
"warnings": [],
|
||||
}
|
||||
|
||||
elif detected["format"] in ["sharegpt", "chatml"]:
|
||||
# First standardize if ShareGPT
|
||||
if detected["format"] == "sharegpt":
|
||||
dataset = standardize_chat_format(
|
||||
dataset, tokenizer, aliases_for_system,
|
||||
aliases_for_user, aliases_for_assistant,
|
||||
batch_size, num_proc
|
||||
dataset,
|
||||
tokenizer,
|
||||
aliases_for_system,
|
||||
aliases_for_user,
|
||||
aliases_for_assistant,
|
||||
batch_size,
|
||||
num_proc,
|
||||
)
|
||||
|
||||
# Then convert to Alpaca
|
||||
|
|
@ -628,7 +700,7 @@ def format_dataset(
|
|||
"requires_manual_mapping": False,
|
||||
"is_image": multimodal_info["is_image"],
|
||||
"multimodal_info": multimodal_info,
|
||||
"warnings": []
|
||||
"warnings": [],
|
||||
}
|
||||
|
||||
else:
|
||||
|
|
@ -642,12 +714,11 @@ def format_dataset(
|
|||
"requires_manual_mapping": True,
|
||||
"is_image": multimodal_info["is_image"],
|
||||
"multimodal_info": multimodal_info,
|
||||
"warnings": warnings
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
# CHATML MODE: Convert to ChatML
|
||||
elif format_type in ["chatml", "conversational", "sharegpt"]:
|
||||
|
||||
if detected["format"] == "alpaca":
|
||||
converted = convert_alpaca_to_chatml(dataset, batch_size, num_proc)
|
||||
return {
|
||||
|
|
@ -659,14 +730,18 @@ def format_dataset(
|
|||
"requires_manual_mapping": False,
|
||||
"is_image": multimodal_info["is_image"],
|
||||
"multimodal_info": multimodal_info,
|
||||
"warnings": []
|
||||
"warnings": [],
|
||||
}
|
||||
|
||||
elif detected["format"] == "sharegpt":
|
||||
standardized = standardize_chat_format(
|
||||
dataset, tokenizer, aliases_for_system,
|
||||
aliases_for_user, aliases_for_assistant,
|
||||
batch_size, num_proc
|
||||
dataset,
|
||||
tokenizer,
|
||||
aliases_for_system,
|
||||
aliases_for_user,
|
||||
aliases_for_assistant,
|
||||
batch_size,
|
||||
num_proc,
|
||||
)
|
||||
return {
|
||||
"dataset": standardized,
|
||||
|
|
@ -677,7 +752,7 @@ def format_dataset(
|
|||
"requires_manual_mapping": False,
|
||||
"is_image": multimodal_info["is_image"],
|
||||
"multimodal_info": multimodal_info,
|
||||
"warnings": []
|
||||
"warnings": [],
|
||||
}
|
||||
|
||||
elif detected["format"] == "chatml":
|
||||
|
|
@ -690,7 +765,7 @@ def format_dataset(
|
|||
"requires_manual_mapping": False,
|
||||
"is_image": multimodal_info["is_image"],
|
||||
"multimodal_info": multimodal_info,
|
||||
"warnings": []
|
||||
"warnings": [],
|
||||
}
|
||||
|
||||
else:
|
||||
|
|
@ -698,9 +773,13 @@ def format_dataset(
|
|||
if detected["chat_column"]:
|
||||
try:
|
||||
standardized = standardize_chat_format(
|
||||
dataset, tokenizer, aliases_for_system,
|
||||
aliases_for_user, aliases_for_assistant,
|
||||
batch_size, num_proc
|
||||
dataset,
|
||||
tokenizer,
|
||||
aliases_for_system,
|
||||
aliases_for_user,
|
||||
aliases_for_assistant,
|
||||
batch_size,
|
||||
num_proc,
|
||||
)
|
||||
return {
|
||||
"dataset": standardized,
|
||||
|
|
@ -711,7 +790,7 @@ def format_dataset(
|
|||
"requires_manual_mapping": False,
|
||||
"is_image": multimodal_info["is_image"],
|
||||
"multimodal_info": multimodal_info,
|
||||
"warnings": warnings
|
||||
"warnings": warnings,
|
||||
}
|
||||
except Exception as e:
|
||||
warnings.append(f"Standardization failed: {e}")
|
||||
|
|
@ -725,7 +804,7 @@ def format_dataset(
|
|||
"requires_manual_mapping": True,
|
||||
"is_image": multimodal_info["is_image"],
|
||||
"multimodal_info": multimodal_info,
|
||||
"warnings": warnings
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
else:
|
||||
|
|
@ -737,25 +816,34 @@ def format_and_template_dataset(
|
|||
model_name,
|
||||
tokenizer,
|
||||
is_vlm = False,
|
||||
format_type="auto",
|
||||
format_type = "auto",
|
||||
# VLM-specific parameters
|
||||
vlm_instruction=None, # Now optional - will auto-generate
|
||||
vlm_text_column=None,
|
||||
vlm_image_column=None,
|
||||
dataset_name=None,
|
||||
|
||||
custom_prompt_template=None,
|
||||
add_eos_token=False,
|
||||
remove_bos_prefix=False,
|
||||
custom_format_mapping=None,
|
||||
auto_detect_custom=True,
|
||||
auto_detect_mapping=True,
|
||||
aliases_for_system=["system",],
|
||||
aliases_for_user=["user", "human", "input",],
|
||||
aliases_for_assistant=["gpt", "assistant", "output",],
|
||||
batch_size=1000,
|
||||
num_proc=None,
|
||||
progress_callback=None,
|
||||
vlm_instruction = None, # Now optional - will auto-generate
|
||||
vlm_text_column = None,
|
||||
vlm_image_column = None,
|
||||
dataset_name = None,
|
||||
custom_prompt_template = None,
|
||||
add_eos_token = False,
|
||||
remove_bos_prefix = False,
|
||||
custom_format_mapping = None,
|
||||
auto_detect_custom = True,
|
||||
auto_detect_mapping = True,
|
||||
aliases_for_system = [
|
||||
"system",
|
||||
],
|
||||
aliases_for_user = [
|
||||
"user",
|
||||
"human",
|
||||
"input",
|
||||
],
|
||||
aliases_for_assistant = [
|
||||
"gpt",
|
||||
"assistant",
|
||||
"output",
|
||||
],
|
||||
batch_size = 1000,
|
||||
num_proc = None,
|
||||
progress_callback = None,
|
||||
):
|
||||
"""
|
||||
Convenience function that combines format_dataset and apply_chat_template_to_dataset.
|
||||
|
|
@ -786,25 +874,27 @@ def format_and_template_dataset(
|
|||
# Expect mapping like: {"image_col": "image", "caption_col": "text"}
|
||||
user_vlm_image_column = None
|
||||
user_vlm_text_column = None
|
||||
|
||||
|
||||
for col, role in custom_format_mapping.items():
|
||||
if role == "image":
|
||||
user_vlm_image_column = col
|
||||
elif role in ["text", "user", "caption", "assistant"]:
|
||||
user_vlm_text_column = col
|
||||
|
||||
|
||||
if user_vlm_image_column and user_vlm_text_column:
|
||||
try:
|
||||
dataset = convert_to_vlm_format(
|
||||
dataset,
|
||||
instruction=vlm_instruction,
|
||||
text_column=user_vlm_text_column,
|
||||
image_column=user_vlm_image_column,
|
||||
dataset_name=dataset_name,
|
||||
progress_callback=progress_callback,
|
||||
instruction = vlm_instruction,
|
||||
text_column = user_vlm_text_column,
|
||||
image_column = user_vlm_image_column,
|
||||
dataset_name = dataset_name,
|
||||
progress_callback = progress_callback,
|
||||
)
|
||||
warnings.append(f"Applied user VLM mapping: image='{user_vlm_image_column}', text='{user_vlm_text_column}'")
|
||||
|
||||
warnings.append(
|
||||
f"Applied user VLM mapping: image='{user_vlm_image_column}', text='{user_vlm_text_column}'"
|
||||
)
|
||||
|
||||
return {
|
||||
"dataset": dataset,
|
||||
"detected_format": "user_mapped",
|
||||
|
|
@ -826,7 +916,9 @@ def format_and_template_dataset(
|
|||
f"text='{user_vlm_text_column}') failed: {e} — "
|
||||
f"falling back to auto-detection"
|
||||
)
|
||||
logger.info(f"⚠️ User VLM mapping failed, falling back to auto-detection...")
|
||||
logger.info(
|
||||
f"⚠️ User VLM mapping failed, falling back to auto-detection..."
|
||||
)
|
||||
custom_format_mapping = None # clear so auto-detection runs below
|
||||
else:
|
||||
errors.append(
|
||||
|
|
@ -850,10 +942,13 @@ def format_and_template_dataset(
|
|||
if vlm_structure["format"] == "vlm_messages_llava":
|
||||
try:
|
||||
dataset = convert_llava_to_vlm_format(dataset)
|
||||
warnings.append("Converted from Llava format (image indices) to standard VLM format")
|
||||
warnings.append(
|
||||
"Converted from Llava format (image indices) to standard VLM format"
|
||||
)
|
||||
except Exception as e:
|
||||
errors.append(f"Failed to convert Llava format: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
|
||||
return {
|
||||
|
|
@ -872,15 +967,18 @@ def format_and_template_dataset(
|
|||
try:
|
||||
dataset = convert_sharegpt_with_images_to_vlm_format(
|
||||
dataset,
|
||||
image_column=vlm_structure["image_column"],
|
||||
messages_column=vlm_structure["messages_column"],
|
||||
dataset_name=dataset_name,
|
||||
progress_callback=progress_callback,
|
||||
image_column = vlm_structure["image_column"],
|
||||
messages_column = vlm_structure["messages_column"],
|
||||
dataset_name = dataset_name,
|
||||
progress_callback = progress_callback,
|
||||
)
|
||||
warnings.append(
|
||||
"Converted from ShareGPT+image format to standard VLM format"
|
||||
)
|
||||
warnings.append("Converted from ShareGPT+image format to standard VLM format")
|
||||
except Exception as e:
|
||||
errors.append(f"Failed to convert ShareGPT+image format: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
|
||||
return {
|
||||
|
|
@ -910,14 +1008,18 @@ def format_and_template_dataset(
|
|||
friendly = None
|
||||
try:
|
||||
from .llm_assist import llm_generate_dataset_warning
|
||||
|
||||
friendly = llm_generate_dataset_warning(
|
||||
issues, dataset_name=dataset_name, modality="vision",
|
||||
column_names=columns,
|
||||
issues,
|
||||
dataset_name = dataset_name,
|
||||
modality = "vision",
|
||||
column_names = columns,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
errors.append(
|
||||
friendly or f"Could not auto-detect image/text columns. Found: {vlm_structure}. "
|
||||
friendly
|
||||
or f"Could not auto-detect image/text columns. Found: {vlm_structure}. "
|
||||
)
|
||||
return {
|
||||
"dataset": dataset,
|
||||
|
|
@ -933,21 +1035,26 @@ def format_and_template_dataset(
|
|||
try:
|
||||
dataset = convert_to_vlm_format(
|
||||
dataset,
|
||||
instruction=vlm_instruction,
|
||||
text_column=vlm_text_column,
|
||||
image_column=vlm_image_column,
|
||||
dataset_name=dataset_name,
|
||||
progress_callback=progress_callback,
|
||||
instruction = vlm_instruction,
|
||||
text_column = vlm_text_column,
|
||||
image_column = vlm_image_column,
|
||||
dataset_name = dataset_name,
|
||||
progress_callback = progress_callback,
|
||||
)
|
||||
|
||||
if vlm_instruction:
|
||||
warnings.append(f"Using user-provided instruction: '{vlm_instruction}'")
|
||||
warnings.append(
|
||||
f"Using user-provided instruction: '{vlm_instruction}'"
|
||||
)
|
||||
else:
|
||||
warnings.append("Auto-generated instruction based on dataset analysis")
|
||||
warnings.append(
|
||||
"Auto-generated instruction based on dataset analysis"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
errors.append(f"Failed to convert to VLM format: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
|
||||
return {
|
||||
|
|
@ -987,41 +1094,45 @@ def format_and_template_dataset(
|
|||
# Step 1: Format the dataset
|
||||
dataset_info = format_dataset(
|
||||
dataset,
|
||||
format_type=format_type,
|
||||
tokenizer=tokenizer,
|
||||
auto_detect_custom=auto_detect_custom,
|
||||
custom_format_mapping=custom_format_mapping,
|
||||
aliases_for_system=aliases_for_system,
|
||||
aliases_for_user=aliases_for_user,
|
||||
aliases_for_assistant=aliases_for_assistant,
|
||||
batch_size=batch_size,
|
||||
num_proc=num_proc,
|
||||
format_type = format_type,
|
||||
tokenizer = tokenizer,
|
||||
auto_detect_custom = auto_detect_custom,
|
||||
custom_format_mapping = custom_format_mapping,
|
||||
aliases_for_system = aliases_for_system,
|
||||
aliases_for_user = aliases_for_user,
|
||||
aliases_for_assistant = aliases_for_assistant,
|
||||
batch_size = batch_size,
|
||||
num_proc = num_proc,
|
||||
)
|
||||
|
||||
# Step 2: Apply chat template
|
||||
# Gemma emits a leading <bos> that must be stripped for text-only chatml/sharegpt.
|
||||
is_alpaca = format_type == "alpaca" or (format_type == "auto" and dataset_info["detected_format"] == "alpaca")
|
||||
is_alpaca = format_type == "alpaca" or (
|
||||
format_type == "auto" and dataset_info["detected_format"] == "alpaca"
|
||||
)
|
||||
is_gemma = "gemma" in model_name.lower()
|
||||
if is_gemma and not dataset_info["is_image"] and not is_alpaca:
|
||||
remove_bos_prefix = True
|
||||
template_result = apply_chat_template_to_dataset(
|
||||
dataset_info=dataset_info,
|
||||
tokenizer=tokenizer,
|
||||
model_name=model_name,
|
||||
custom_prompt_template=custom_prompt_template,
|
||||
add_eos_token=add_eos_token,
|
||||
remove_bos_prefix=remove_bos_prefix,
|
||||
custom_format_mapping=custom_format_mapping,
|
||||
auto_detect_mapping=auto_detect_mapping,
|
||||
batch_size=batch_size,
|
||||
num_proc=num_proc,
|
||||
dataset_info = dataset_info,
|
||||
tokenizer = tokenizer,
|
||||
model_name = model_name,
|
||||
custom_prompt_template = custom_prompt_template,
|
||||
add_eos_token = add_eos_token,
|
||||
remove_bos_prefix = remove_bos_prefix,
|
||||
custom_format_mapping = custom_format_mapping,
|
||||
auto_detect_mapping = auto_detect_mapping,
|
||||
batch_size = batch_size,
|
||||
num_proc = num_proc,
|
||||
)
|
||||
|
||||
# Step 3: Generate summary
|
||||
summary = get_dataset_info_summary(dataset_info)
|
||||
|
||||
# Combine results
|
||||
all_warnings = dataset_info.get("warnings", []) + template_result.get("warnings", [])
|
||||
all_warnings = dataset_info.get("warnings", []) + template_result.get(
|
||||
"warnings", []
|
||||
)
|
||||
all_errors = template_result.get("errors", [])
|
||||
|
||||
# If format_dataset returned "unknown" but apply_chat_template rescued
|
||||
|
|
|
|||
|
|
@ -12,19 +12,28 @@ import os
|
|||
|
||||
from datasets import IterableDataset
|
||||
from loggers import get_logger
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
|
||||
|
||||
def standardize_chat_format(
|
||||
dataset,
|
||||
tokenizer=None,
|
||||
aliases_for_system=["system",],
|
||||
aliases_for_user=["user", "human", "input",],
|
||||
aliases_for_assistant=["gpt", "assistant", "output",],
|
||||
batch_size=1000,
|
||||
num_proc=None,
|
||||
tokenizer = None,
|
||||
aliases_for_system = [
|
||||
"system",
|
||||
],
|
||||
aliases_for_user = [
|
||||
"user",
|
||||
"human",
|
||||
"input",
|
||||
],
|
||||
aliases_for_assistant = [
|
||||
"gpt",
|
||||
"assistant",
|
||||
"output",
|
||||
],
|
||||
batch_size = 1000,
|
||||
num_proc = None,
|
||||
):
|
||||
"""
|
||||
Our own standardization function that handles BOTH messages and conversations.
|
||||
|
|
@ -67,22 +76,25 @@ def standardize_chat_format(
|
|||
return dataset # Unexpected structure
|
||||
|
||||
keys = list(uniques.keys())
|
||||
length_first = len(set(uniques[keys[0]]))
|
||||
length_first = len(set(uniques[keys[0]]))
|
||||
length_second = len(set(uniques[keys[1]]))
|
||||
|
||||
# Determine which is role and which is content
|
||||
if length_first < length_second:
|
||||
role_key = keys[0]
|
||||
role_key = keys[0]
|
||||
content_key = keys[1]
|
||||
else:
|
||||
role_key = keys[1]
|
||||
role_key = keys[1]
|
||||
content_key = keys[0]
|
||||
|
||||
# Mapping for aliases
|
||||
aliases_mapping = {}
|
||||
for x in aliases_for_system: aliases_mapping[x] = "system"
|
||||
for x in aliases_for_user: aliases_mapping[x] = "user"
|
||||
for x in aliases_for_assistant: aliases_mapping[x] = "assistant"
|
||||
for x in aliases_for_system:
|
||||
aliases_mapping[x] = "system"
|
||||
for x in aliases_for_user:
|
||||
aliases_mapping[x] = "user"
|
||||
for x in aliases_for_assistant:
|
||||
aliases_mapping[x] = "assistant"
|
||||
|
||||
def _standardize_dataset(examples):
|
||||
convos = examples[chat_column]
|
||||
|
|
@ -109,10 +121,9 @@ def standardize_chat_format(
|
|||
|
||||
return {chat_column: all_convos}
|
||||
|
||||
|
||||
dataset_map_kwargs = {
|
||||
'batched': True,
|
||||
'batch_size': batch_size,
|
||||
"batched": True,
|
||||
"batch_size": batch_size,
|
||||
}
|
||||
|
||||
if not isinstance(dataset, IterableDataset):
|
||||
|
|
@ -123,13 +134,13 @@ def standardize_chat_format(
|
|||
else:
|
||||
num_proc = safe_num_proc(num_proc)
|
||||
|
||||
dataset_map_kwargs['num_proc'] = num_proc
|
||||
dataset_map_kwargs['desc'] = "Standardizing chat format"
|
||||
dataset_map_kwargs["num_proc"] = num_proc
|
||||
dataset_map_kwargs["desc"] = "Standardizing chat format"
|
||||
|
||||
return dataset.map(_standardize_dataset, **dataset_map_kwargs)
|
||||
|
||||
|
||||
def convert_chatml_to_alpaca(dataset, batch_size=1000, num_proc=None):
|
||||
def convert_chatml_to_alpaca(dataset, batch_size = 1000, num_proc = None):
|
||||
"""
|
||||
Converts ChatML format (messages OR conversations) to Alpaca format.
|
||||
Handles both standardized and ShareGPT formats.
|
||||
|
|
@ -142,10 +153,16 @@ def convert_chatml_to_alpaca(dataset, batch_size=1000, num_proc=None):
|
|||
|
||||
def _convert(examples):
|
||||
# Auto-detect which column name is used
|
||||
chatml_data = examples.get("messages") or examples.get("conversations") or examples.get("texts")
|
||||
chatml_data = (
|
||||
examples.get("messages")
|
||||
or examples.get("conversations")
|
||||
or examples.get("texts")
|
||||
)
|
||||
|
||||
if chatml_data is None:
|
||||
raise ValueError("No 'messages' or 'conversations' or 'texts' column found.")
|
||||
raise ValueError(
|
||||
"No 'messages' or 'conversations' or 'texts' column found."
|
||||
)
|
||||
|
||||
instructions = []
|
||||
outputs = []
|
||||
|
|
@ -172,15 +189,11 @@ def convert_chatml_to_alpaca(dataset, batch_size=1000, num_proc=None):
|
|||
inputs.append("") # Alpaca typically has empty input
|
||||
outputs.append(output)
|
||||
|
||||
return {
|
||||
"instruction": instructions,
|
||||
"input": inputs,
|
||||
"output": outputs
|
||||
}
|
||||
return {"instruction": instructions, "input": inputs, "output": outputs}
|
||||
|
||||
dataset_map_kwargs = {
|
||||
'batched': True,
|
||||
'batch_size': batch_size,
|
||||
"batched": True,
|
||||
"batch_size": batch_size,
|
||||
}
|
||||
|
||||
if not isinstance(dataset, IterableDataset):
|
||||
|
|
@ -191,13 +204,13 @@ def convert_chatml_to_alpaca(dataset, batch_size=1000, num_proc=None):
|
|||
else:
|
||||
num_proc = safe_num_proc(num_proc)
|
||||
|
||||
dataset_map_kwargs['num_proc'] = num_proc
|
||||
dataset_map_kwargs['desc'] = "Converting ChatML to Alpaca format"
|
||||
dataset_map_kwargs["num_proc"] = num_proc
|
||||
dataset_map_kwargs["desc"] = "Converting ChatML to Alpaca format"
|
||||
|
||||
return dataset.map(_convert, **dataset_map_kwargs)
|
||||
|
||||
|
||||
def convert_alpaca_to_chatml(dataset, batch_size=1000, num_proc=None):
|
||||
def convert_alpaca_to_chatml(dataset, batch_size = 1000, num_proc = None):
|
||||
"""
|
||||
Converts Alpaca format to ChatML format.
|
||||
|
||||
|
|
@ -222,15 +235,15 @@ def convert_alpaca_to_chatml(dataset, batch_size=1000, num_proc=None):
|
|||
# Build conversation in standard ChatML format
|
||||
convo = [
|
||||
{"role": "user", "content": user_content},
|
||||
{"role": "assistant", "content": output}
|
||||
{"role": "assistant", "content": output},
|
||||
]
|
||||
conversations.append(convo)
|
||||
|
||||
return {"conversations": conversations}
|
||||
|
||||
dataset_map_kwargs = {
|
||||
'batched': True,
|
||||
'batch_size': batch_size,
|
||||
"batched": True,
|
||||
"batch_size": batch_size,
|
||||
}
|
||||
|
||||
if not isinstance(dataset, IterableDataset):
|
||||
|
|
@ -241,8 +254,8 @@ def convert_alpaca_to_chatml(dataset, batch_size=1000, num_proc=None):
|
|||
else:
|
||||
num_proc = safe_num_proc(num_proc)
|
||||
|
||||
dataset_map_kwargs['num_proc'] = num_proc
|
||||
dataset_map_kwargs['desc'] = "Converting Alpaca to ChatML format"
|
||||
dataset_map_kwargs["num_proc"] = num_proc
|
||||
dataset_map_kwargs["desc"] = "Converting Alpaca to ChatML format"
|
||||
|
||||
return dataset.map(_convert, **dataset_map_kwargs)
|
||||
|
||||
|
|
@ -262,11 +275,11 @@ def _format_eta(seconds):
|
|||
|
||||
def convert_to_vlm_format(
|
||||
dataset,
|
||||
instruction=None,
|
||||
text_column="text",
|
||||
image_column="image",
|
||||
dataset_name=None,
|
||||
progress_callback=None,
|
||||
instruction = None,
|
||||
text_column = "text",
|
||||
image_column = "image",
|
||||
dataset_name = None,
|
||||
progress_callback = None,
|
||||
):
|
||||
"""
|
||||
Converts simple {image, text} format to VLM messages format.
|
||||
|
|
@ -290,27 +303,31 @@ def convert_to_vlm_format(
|
|||
def _notify(msg):
|
||||
"""Send status update to the training overlay if callback is available."""
|
||||
if progress_callback:
|
||||
progress_callback(status_message=msg)
|
||||
progress_callback(status_message = msg)
|
||||
|
||||
# Generate smart instruction if not provided
|
||||
if instruction is None:
|
||||
instruction_info = generate_smart_vlm_instruction(
|
||||
dataset,
|
||||
text_column=text_column,
|
||||
image_column=image_column,
|
||||
dataset_name=dataset_name,
|
||||
text_column = text_column,
|
||||
image_column = image_column,
|
||||
dataset_name = dataset_name,
|
||||
)
|
||||
|
||||
instruction = instruction_info["instruction"]
|
||||
instruction_column = instruction_info.get("instruction_column")
|
||||
uses_dynamic = instruction_info["uses_dynamic_instruction"]
|
||||
|
||||
logger.info(f"📝 Auto-detected instruction type: {instruction_info['instruction_type']}")
|
||||
logger.info(
|
||||
f"📝 Auto-detected instruction type: {instruction_info['instruction_type']}"
|
||||
)
|
||||
logger.info(f"📝 Confidence: {instruction_info['confidence']:.2f}")
|
||||
if not uses_dynamic:
|
||||
logger.info(f"📝 Using instruction: '{instruction}'")
|
||||
else:
|
||||
logger.info(f"📝 Using dynamic instructions from column: '{instruction_column}'")
|
||||
logger.info(
|
||||
f"📝 Using dynamic instructions from column: '{instruction_column}'"
|
||||
)
|
||||
else:
|
||||
instruction_column = None
|
||||
uses_dynamic = False
|
||||
|
|
@ -324,13 +341,17 @@ def convert_to_vlm_format(
|
|||
if image_data.startswith(("http://", "https://")):
|
||||
import fsspec
|
||||
from io import BytesIO
|
||||
with fsspec.open(image_data, "rb", expand=True) as f:
|
||||
|
||||
with fsspec.open(image_data, "rb", expand = True) as f:
|
||||
image_data = Image.open(BytesIO(f.read())).convert("RGB")
|
||||
elif _image_lookup is not None and image_data in _image_lookup:
|
||||
# Bare filename → resolve via HF repo lookup
|
||||
from huggingface_hub import hf_hub_download
|
||||
|
||||
local_path = hf_hub_download(
|
||||
dataset_name, _image_lookup[image_data], repo_type="dataset",
|
||||
dataset_name,
|
||||
_image_lookup[image_data],
|
||||
repo_type = "dataset",
|
||||
)
|
||||
image_data = Image.open(local_path).convert("RGB")
|
||||
else:
|
||||
|
|
@ -340,6 +361,7 @@ def convert_to_vlm_format(
|
|||
text_data = sample[text_column]
|
||||
if isinstance(text_data, list) and len(text_data) > 0:
|
||||
import random
|
||||
|
||||
text_data = random.choice(text_data)
|
||||
|
||||
# Get instruction (static or dynamic)
|
||||
|
|
@ -354,15 +376,10 @@ def convert_to_vlm_format(
|
|||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": current_instruction},
|
||||
{"type": "image", "image": image_data} # PIL object
|
||||
]
|
||||
{"type": "image", "image": image_data}, # PIL object
|
||||
],
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": text_data}
|
||||
]
|
||||
}
|
||||
{"role": "assistant", "content": [{"type": "text", "text": text_data}]},
|
||||
]
|
||||
|
||||
# Return dict with messages
|
||||
|
|
@ -370,13 +387,15 @@ def convert_to_vlm_format(
|
|||
|
||||
total = len(dataset)
|
||||
first_image = next(iter(dataset))[image_column]
|
||||
has_urls = isinstance(first_image, str) and first_image.startswith(("http://", "https://"))
|
||||
has_urls = isinstance(first_image, str) and first_image.startswith(
|
||||
("http://", "https://")
|
||||
)
|
||||
|
||||
# ── Bare-filename detection: images stored as filenames (e.g. "img_001.png")
|
||||
# that don't exist locally. Build a basename→repo_path lookup so we can
|
||||
# resolve them via hf_hub_download during conversion.
|
||||
_image_lookup = None
|
||||
_IMAGE_EXTS = ('.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.tiff')
|
||||
_IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tiff")
|
||||
if (
|
||||
not has_urls
|
||||
and isinstance(first_image, str)
|
||||
|
|
@ -385,18 +404,25 @@ def convert_to_vlm_format(
|
|||
):
|
||||
try:
|
||||
from huggingface_hub import HfApi
|
||||
|
||||
_notify("Resolving image filenames from HF repo...")
|
||||
logger.info(f"🔍 Image column contains bare filenames (e.g. '{first_image}') — building repo lookup...")
|
||||
repo_files = HfApi().list_repo_files(dataset_name, repo_type="dataset")
|
||||
logger.info(
|
||||
f"🔍 Image column contains bare filenames (e.g. '{first_image}') — building repo lookup..."
|
||||
)
|
||||
repo_files = HfApi().list_repo_files(dataset_name, repo_type = "dataset")
|
||||
_image_lookup = {
|
||||
os.path.basename(f): f
|
||||
for f in repo_files
|
||||
if any(f.lower().endswith(ext) for ext in _IMAGE_EXTS)
|
||||
}
|
||||
if first_image in _image_lookup:
|
||||
logger.info(f"✅ Matched {len(_image_lookup)} image files in repo (e.g. '{first_image}' → '{_image_lookup[first_image]}')")
|
||||
logger.info(
|
||||
f"✅ Matched {len(_image_lookup)} image files in repo (e.g. '{first_image}' → '{_image_lookup[first_image]}')"
|
||||
)
|
||||
else:
|
||||
logger.info(f"⚠️ Built lookup with {len(_image_lookup)} images but '{first_image}' not found — falling back to local open")
|
||||
logger.info(
|
||||
f"⚠️ Built lookup with {len(_image_lookup)} images but '{first_image}' not found — falling back to local open"
|
||||
)
|
||||
_image_lookup = None
|
||||
except Exception as e:
|
||||
logger.info(f"⚠️ Failed to build HF repo image lookup: {e}")
|
||||
|
|
@ -413,15 +439,19 @@ def convert_to_vlm_format(
|
|||
|
||||
num_workers = safe_num_proc()
|
||||
_notify(f"Probing {PROBE_SIZE} image URLs with {num_workers} workers...")
|
||||
logger.info(f"🔍 Probing {PROBE_SIZE}/{total} image URLs with {num_workers} workers...")
|
||||
logger.info(
|
||||
f"🔍 Probing {PROBE_SIZE}/{total} image URLs with {num_workers} workers..."
|
||||
)
|
||||
|
||||
probe_samples = [dataset[i] for i in range(PROBE_SIZE)]
|
||||
probe_ok = 0
|
||||
probe_fail = 0
|
||||
probe_start = time.time()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=num_workers) as executor:
|
||||
futures = {executor.submit(_convert_single_sample, s): s for s in probe_samples}
|
||||
with ThreadPoolExecutor(max_workers = num_workers) as executor:
|
||||
futures = {
|
||||
executor.submit(_convert_single_sample, s): s for s in probe_samples
|
||||
}
|
||||
for future in as_completed(futures):
|
||||
try:
|
||||
future.result()
|
||||
|
|
@ -443,9 +473,12 @@ def convert_to_vlm_format(
|
|||
friendly = None
|
||||
try:
|
||||
from .llm_assist import llm_generate_dataset_warning
|
||||
|
||||
friendly = llm_generate_dataset_warning(
|
||||
issues, dataset_name=dataset_name, modality="vision",
|
||||
column_names=[image_column, text_column],
|
||||
issues,
|
||||
dataset_name = dataset_name,
|
||||
modality = "vision",
|
||||
column_names = [image_column, text_column],
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
|
@ -471,7 +504,9 @@ def convert_to_vlm_format(
|
|||
if probe_fail > 0:
|
||||
info_msg += f" | {fail_rate:.0%} broken URLs will be skipped"
|
||||
|
||||
logger.info(f"✅ Probe passed: {probe_ok}/{probe_total} ok, {probe_fail} failed ({fail_rate:.0%}), {throughput:.1f} img/s")
|
||||
logger.info(
|
||||
f"✅ Probe passed: {probe_ok}/{probe_total} ok, {probe_fail} failed ({fail_rate:.0%}), {throughput:.1f} img/s"
|
||||
)
|
||||
logger.info(f"⏱️ Estimated time for {total:,} samples: ~{eta_str}")
|
||||
_notify(info_msg)
|
||||
|
||||
|
|
@ -496,8 +531,11 @@ def convert_to_vlm_format(
|
|||
batch_end = min(batch_start + batch_size, total)
|
||||
batch_samples = [dataset[i] for i in range(batch_start, batch_end)]
|
||||
|
||||
with ThreadPoolExecutor(max_workers=num_workers) as executor:
|
||||
futures = {executor.submit(_convert_single_sample, s): i for i, s in enumerate(batch_samples)}
|
||||
with ThreadPoolExecutor(max_workers = num_workers) as executor:
|
||||
futures = {
|
||||
executor.submit(_convert_single_sample, s): i
|
||||
for i, s in enumerate(batch_samples)
|
||||
}
|
||||
batch_results = [None] * len(batch_samples)
|
||||
for future in as_completed(futures):
|
||||
idx = futures[future]
|
||||
|
|
@ -506,9 +544,13 @@ def convert_to_vlm_format(
|
|||
except Exception as e:
|
||||
failed_count += 1
|
||||
if failed_count == 1:
|
||||
print(f"⚠️ First VLM conversion failure: {type(e).__name__}: {e}")
|
||||
print(
|
||||
f"⚠️ First VLM conversion failure: {type(e).__name__}: {e}"
|
||||
)
|
||||
if failed_count == 1:
|
||||
logger.info(f"⚠️ First VLM conversion failure: {type(e).__name__}: {e}")
|
||||
logger.info(
|
||||
f"⚠️ First VLM conversion failure: {type(e).__name__}: {e}"
|
||||
)
|
||||
|
||||
converted_list.extend(r for r in batch_results if r is not None)
|
||||
|
||||
|
|
@ -519,11 +561,13 @@ def convert_to_vlm_format(
|
|||
remaining_time = (total - done) / rate if rate > 0 else 0
|
||||
eta_str = _format_eta(remaining_time)
|
||||
progress_msg = f"Downloading images: {done:,}/{total:,} ({done*100//total}%) | ~{eta_str} remaining | {failed_count} skipped"
|
||||
logger.info(f" [{done}/{total}] {rate:.1f} img/s, {failed_count} failed, ETA {eta_str}")
|
||||
logger.info(
|
||||
f" [{done}/{total}] {rate:.1f} img/s, {failed_count} failed, ETA {eta_str}"
|
||||
)
|
||||
_notify(progress_msg)
|
||||
else:
|
||||
# Sequential conversion for local/embedded images (fast, no I/O bottleneck)
|
||||
pbar = tqdm(dataset, total=total, desc="Converting VLM samples", unit="sample")
|
||||
pbar = tqdm(dataset, total = total, desc = "Converting VLM samples", unit = "sample")
|
||||
for sample in pbar:
|
||||
try:
|
||||
converted_list.append(_convert_single_sample(sample))
|
||||
|
|
@ -534,13 +578,17 @@ def convert_to_vlm_format(
|
|||
print(f"⚠️ First VLM conversion failure: {type(e).__name__}: {e}")
|
||||
if failed_count == 1:
|
||||
# Log the first failure to aid debugging
|
||||
logger.info(f"⚠️ First VLM conversion failure: {type(e).__name__}: {e}")
|
||||
pbar.set_postfix(ok=len(converted_list), failed=failed_count, refresh=False)
|
||||
logger.info(
|
||||
f"⚠️ First VLM conversion failure: {type(e).__name__}: {e}"
|
||||
)
|
||||
pbar.set_postfix(ok = len(converted_list), failed = failed_count, refresh = False)
|
||||
pbar.close()
|
||||
|
||||
if failed_count > 0:
|
||||
fail_rate = failed_count / total
|
||||
logger.info(f"⚠️ Skipped {failed_count}/{total} ({fail_rate:.0%}) samples with broken/unreachable images")
|
||||
logger.info(
|
||||
f"⚠️ Skipped {failed_count}/{total} ({fail_rate:.0%}) samples with broken/unreachable images"
|
||||
)
|
||||
# For datasets that skipped the probe (small URL datasets), check fail rate now
|
||||
if has_urls and fail_rate >= MAX_FAIL_RATE:
|
||||
issues = [
|
||||
|
|
@ -550,9 +598,12 @@ def convert_to_vlm_format(
|
|||
friendly = None
|
||||
try:
|
||||
from .llm_assist import llm_generate_dataset_warning
|
||||
|
||||
friendly = llm_generate_dataset_warning(
|
||||
issues, dataset_name=dataset_name, modality="vision",
|
||||
column_names=[image_column, text_column],
|
||||
issues,
|
||||
dataset_name = dataset_name,
|
||||
modality = "vision",
|
||||
column_names = [image_column, text_column],
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
|
@ -573,14 +624,18 @@ def convert_to_vlm_format(
|
|||
friendly = None
|
||||
try:
|
||||
from .llm_assist import llm_generate_dataset_warning
|
||||
|
||||
friendly = llm_generate_dataset_warning(
|
||||
issues, dataset_name=dataset_name, modality="vision",
|
||||
column_names=[image_column, text_column],
|
||||
issues,
|
||||
dataset_name = dataset_name,
|
||||
modality = "vision",
|
||||
column_names = [image_column, text_column],
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
raise ValueError(
|
||||
friendly or (
|
||||
friendly
|
||||
or (
|
||||
f"All {total} samples failed during VLM conversion — no usable images found. "
|
||||
"This dataset may contain only image URLs that are no longer accessible."
|
||||
)
|
||||
|
|
@ -595,10 +650,10 @@ def convert_to_vlm_format(
|
|||
|
||||
def convert_sharegpt_with_images_to_vlm_format(
|
||||
dataset,
|
||||
image_column="image",
|
||||
messages_column="conversations",
|
||||
dataset_name=None,
|
||||
progress_callback=None,
|
||||
image_column = "image",
|
||||
messages_column = "conversations",
|
||||
dataset_name = None,
|
||||
progress_callback = None,
|
||||
):
|
||||
"""
|
||||
Converts ShareGPT/ChatML datasets that have a separate image column and
|
||||
|
|
@ -619,16 +674,18 @@ def convert_sharegpt_with_images_to_vlm_format(
|
|||
from PIL import Image
|
||||
from tqdm import tqdm
|
||||
|
||||
_IMAGE_EXTS = ('.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.tiff')
|
||||
_IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tiff")
|
||||
_ROLE_MAP = {
|
||||
"human": "user", "user": "user",
|
||||
"gpt": "assistant", "assistant": "assistant",
|
||||
"human": "user",
|
||||
"user": "user",
|
||||
"gpt": "assistant",
|
||||
"assistant": "assistant",
|
||||
"system": "system",
|
||||
}
|
||||
|
||||
def _notify(msg):
|
||||
if progress_callback:
|
||||
progress_callback(status_message=msg)
|
||||
progress_callback(status_message = msg)
|
||||
|
||||
# ── Resolve image loading strategy (same 3-tier as convert_to_vlm_format) ──
|
||||
total = len(dataset)
|
||||
|
|
@ -643,9 +700,12 @@ def convert_sharegpt_with_images_to_vlm_format(
|
|||
):
|
||||
try:
|
||||
from huggingface_hub import HfApi
|
||||
|
||||
_notify("Resolving image filenames from HF repo...")
|
||||
logger.info(f"🔍 Image column contains bare filenames (e.g. '{first_image}') — building repo lookup...")
|
||||
repo_files = HfApi().list_repo_files(dataset_name, repo_type="dataset")
|
||||
logger.info(
|
||||
f"🔍 Image column contains bare filenames (e.g. '{first_image}') — building repo lookup..."
|
||||
)
|
||||
repo_files = HfApi().list_repo_files(dataset_name, repo_type = "dataset")
|
||||
_image_lookup = {
|
||||
os.path.basename(f): f
|
||||
for f in repo_files
|
||||
|
|
@ -656,9 +716,13 @@ def convert_sharegpt_with_images_to_vlm_format(
|
|||
if any(f.lower().endswith(ext) for ext in _IMAGE_EXTS):
|
||||
_image_lookup[f] = f
|
||||
if first_image in _image_lookup:
|
||||
logger.info(f"✅ Matched {len(_image_lookup)} image files in repo (e.g. '{first_image}' → '{_image_lookup[first_image]}')")
|
||||
logger.info(
|
||||
f"✅ Matched {len(_image_lookup)} image files in repo (e.g. '{first_image}' → '{_image_lookup[first_image]}')"
|
||||
)
|
||||
else:
|
||||
logger.info(f"⚠️ Built lookup with {len(_image_lookup)} images but '{first_image}' not found — falling back to local open")
|
||||
logger.info(
|
||||
f"⚠️ Built lookup with {len(_image_lookup)} images but '{first_image}' not found — falling back to local open"
|
||||
)
|
||||
_image_lookup = None
|
||||
except Exception as e:
|
||||
logger.info(f"⚠️ Failed to build HF repo image lookup: {e}")
|
||||
|
|
@ -666,25 +730,32 @@ def convert_sharegpt_with_images_to_vlm_format(
|
|||
|
||||
def _resolve_image(image_data):
|
||||
"""Resolve image data to a PIL Image object."""
|
||||
if hasattr(image_data, 'size') and hasattr(image_data, 'mode'):
|
||||
if hasattr(image_data, "size") and hasattr(image_data, "mode"):
|
||||
return image_data # Already PIL
|
||||
if isinstance(image_data, str):
|
||||
if image_data.startswith(("http://", "https://")):
|
||||
import fsspec
|
||||
from io import BytesIO
|
||||
with fsspec.open(image_data, "rb", expand=True) as f:
|
||||
|
||||
with fsspec.open(image_data, "rb", expand = True) as f:
|
||||
return Image.open(BytesIO(f.read())).convert("RGB")
|
||||
elif _image_lookup is not None and image_data in _image_lookup:
|
||||
from huggingface_hub import hf_hub_download
|
||||
|
||||
local_path = hf_hub_download(
|
||||
dataset_name, _image_lookup[image_data], repo_type="dataset",
|
||||
dataset_name,
|
||||
_image_lookup[image_data],
|
||||
repo_type = "dataset",
|
||||
)
|
||||
return Image.open(local_path).convert("RGB")
|
||||
else:
|
||||
return Image.open(image_data).convert("RGB")
|
||||
if isinstance(image_data, dict) and ("bytes" in image_data or "path" in image_data):
|
||||
if isinstance(image_data, dict) and (
|
||||
"bytes" in image_data or "path" in image_data
|
||||
):
|
||||
if image_data.get("bytes"):
|
||||
from io import BytesIO
|
||||
|
||||
return Image.open(BytesIO(image_data["bytes"])).convert("RGB")
|
||||
if image_data.get("path"):
|
||||
return Image.open(image_data["path"]).convert("RGB")
|
||||
|
|
@ -726,7 +797,7 @@ def convert_sharegpt_with_images_to_vlm_format(
|
|||
converted_list = []
|
||||
failed_count = 0
|
||||
|
||||
pbar = tqdm(dataset, total=total, desc="Converting ShareGPT+image", unit="sample")
|
||||
pbar = tqdm(dataset, total = total, desc = "Converting ShareGPT+image", unit = "sample")
|
||||
for sample in pbar:
|
||||
try:
|
||||
converted_list.append(_convert_single_sample(sample))
|
||||
|
|
@ -734,11 +805,13 @@ def convert_sharegpt_with_images_to_vlm_format(
|
|||
failed_count += 1
|
||||
if failed_count == 1:
|
||||
logger.info(f"⚠️ First conversion failure: {type(e).__name__}: {e}")
|
||||
pbar.set_postfix(ok=len(converted_list), failed=failed_count, refresh=False)
|
||||
pbar.set_postfix(ok = len(converted_list), failed = failed_count, refresh = False)
|
||||
pbar.close()
|
||||
|
||||
if failed_count > 0:
|
||||
logger.info(f"⚠️ Skipped {failed_count}/{total} ({failed_count*100//total}%) samples")
|
||||
logger.info(
|
||||
f"⚠️ Skipped {failed_count}/{total} ({failed_count*100//total}%) samples"
|
||||
)
|
||||
|
||||
if len(converted_list) == 0:
|
||||
raise ValueError(
|
||||
|
|
@ -764,7 +837,9 @@ def convert_llava_to_vlm_format(dataset):
|
|||
"""
|
||||
from PIL import Image
|
||||
|
||||
logger.info(f"🔄 Converting {len(dataset)} samples from Llava format to standard VLM format...")
|
||||
logger.info(
|
||||
f"🔄 Converting {len(dataset)} samples from Llava format to standard VLM format..."
|
||||
)
|
||||
|
||||
def _convert_single_sample(sample):
|
||||
"""Convert a single llava sample to standard VLM format."""
|
||||
|
|
@ -787,10 +862,12 @@ def convert_llava_to_vlm_format(dataset):
|
|||
if isinstance(pil_image, str):
|
||||
pil_image = Image.open(pil_image).convert("RGB")
|
||||
|
||||
new_content.append({
|
||||
"type": "image",
|
||||
"image": pil_image # Actual PIL object
|
||||
})
|
||||
new_content.append(
|
||||
{
|
||||
"type": "image",
|
||||
"image": pil_image, # Actual PIL object
|
||||
}
|
||||
)
|
||||
else:
|
||||
# No index, try to use first image
|
||||
if len(images) > 0:
|
||||
|
|
@ -798,22 +875,13 @@ def convert_llava_to_vlm_format(dataset):
|
|||
if isinstance(pil_image, str):
|
||||
pil_image = Image.open(pil_image).convert("RGB")
|
||||
|
||||
new_content.append({
|
||||
"type": "image",
|
||||
"image": pil_image
|
||||
})
|
||||
new_content.append({"type": "image", "image": pil_image})
|
||||
|
||||
elif item["type"] == "text":
|
||||
# Keep text as-is (only type + text)
|
||||
new_content.append({
|
||||
"type": "text",
|
||||
"text": item.get("text", "")
|
||||
})
|
||||
new_content.append({"type": "text", "text": item.get("text", "")})
|
||||
|
||||
new_messages.append({
|
||||
"role": msg["role"],
|
||||
"content": new_content
|
||||
})
|
||||
new_messages.append({"role": msg["role"], "content": new_content})
|
||||
|
||||
return {"messages": new_messages}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,10 @@ import re
|
|||
|
||||
def _keyword_in_column(keyword: str, col_name: str) -> bool:
|
||||
"""Word-boundary keyword match to avoid false positives like 'pic' in 'topic'."""
|
||||
return re.search(r'\b' + re.escape(keyword) + r'\b', col_name, re.IGNORECASE) is not None
|
||||
return (
|
||||
re.search(r"\b" + re.escape(keyword) + r"\b", col_name, re.IGNORECASE)
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def detect_dataset_format(dataset):
|
||||
|
|
@ -37,7 +40,7 @@ def detect_dataset_format(dataset):
|
|||
"format": "alpaca",
|
||||
"chat_column": None,
|
||||
"needs_standardization": False,
|
||||
"sample_keys": []
|
||||
"sample_keys": [],
|
||||
}
|
||||
|
||||
# Check for chat-based formats (messages or conversations)
|
||||
|
|
@ -65,7 +68,7 @@ def detect_dataset_format(dataset):
|
|||
"format": "sharegpt",
|
||||
"chat_column": chat_column,
|
||||
"needs_standardization": True,
|
||||
"sample_keys": list(msg_keys)
|
||||
"sample_keys": list(msg_keys),
|
||||
}
|
||||
|
||||
# ChatML uses "role" and "content"
|
||||
|
|
@ -74,7 +77,7 @@ def detect_dataset_format(dataset):
|
|||
"format": "chatml",
|
||||
"chat_column": chat_column,
|
||||
"needs_standardization": False,
|
||||
"sample_keys": list(msg_keys)
|
||||
"sample_keys": list(msg_keys),
|
||||
}
|
||||
|
||||
# Unknown structure but has chat column
|
||||
|
|
@ -83,7 +86,7 @@ def detect_dataset_format(dataset):
|
|||
"format": "unknown",
|
||||
"chat_column": chat_column,
|
||||
"needs_standardization": None,
|
||||
"sample_keys": list(msg_keys)
|
||||
"sample_keys": list(msg_keys),
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
|
|
@ -91,7 +94,7 @@ def detect_dataset_format(dataset):
|
|||
"chat_column": chat_column,
|
||||
"needs_standardization": None,
|
||||
"sample_keys": [],
|
||||
"error": str(e)
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
# No recognized format
|
||||
|
|
@ -99,7 +102,7 @@ def detect_dataset_format(dataset):
|
|||
"format": "unknown",
|
||||
"chat_column": None,
|
||||
"needs_standardization": None,
|
||||
"sample_keys": []
|
||||
"sample_keys": [],
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -120,49 +123,86 @@ def detect_custom_format_heuristic(dataset):
|
|||
|
||||
# Keywords
|
||||
assistant_words = [
|
||||
'output', 'answer', 'response', 'assistant', 'completion',
|
||||
'expected', 'recommendation', 'reply', 'result', 'target',
|
||||
'solution', 'explanation', 'solve'
|
||||
"output",
|
||||
"answer",
|
||||
"response",
|
||||
"assistant",
|
||||
"completion",
|
||||
"expected",
|
||||
"recommendation",
|
||||
"reply",
|
||||
"result",
|
||||
"target",
|
||||
"solution",
|
||||
"explanation",
|
||||
"solve",
|
||||
]
|
||||
|
||||
# Split into high/low priority
|
||||
user_words_high_priority = [
|
||||
'input', 'question', 'query', 'prompt', 'instruction',
|
||||
'request', 'snippet', 'user', 'text',
|
||||
'problem', 'exercise'
|
||||
"input",
|
||||
"question",
|
||||
"query",
|
||||
"prompt",
|
||||
"instruction",
|
||||
"request",
|
||||
"snippet",
|
||||
"user",
|
||||
"text",
|
||||
"problem",
|
||||
"exercise",
|
||||
]
|
||||
user_words_low_priority = ['task'] # Ambiguous - can be user OR system
|
||||
user_words_low_priority = ["task"] # Ambiguous - can be user OR system
|
||||
user_words = user_words_high_priority + user_words_low_priority
|
||||
|
||||
system_words = [
|
||||
'system', 'context', 'description', 'persona', 'role',
|
||||
'template', 'task' # Also in system
|
||||
"system",
|
||||
"context",
|
||||
"description",
|
||||
"persona",
|
||||
"role",
|
||||
"template",
|
||||
"task", # Also in system
|
||||
]
|
||||
|
||||
# Metadata columns to ignore
|
||||
metadata_exact_match = {
|
||||
'id', 'idx', 'index', 'key', 'timestamp', 'date',
|
||||
'metadata', 'source', 'kind', 'type', 'category',
|
||||
'score', 'label', 'tag', 'inference_mode'
|
||||
"id",
|
||||
"idx",
|
||||
"index",
|
||||
"key",
|
||||
"timestamp",
|
||||
"date",
|
||||
"metadata",
|
||||
"source",
|
||||
"kind",
|
||||
"type",
|
||||
"category",
|
||||
"score",
|
||||
"label",
|
||||
"tag",
|
||||
"inference_mode",
|
||||
}
|
||||
|
||||
metadata_prefix_patterns = [
|
||||
'problem_type', 'problem_source',
|
||||
'generation_model', 'pass_rate',
|
||||
"problem_type",
|
||||
"problem_source",
|
||||
"generation_model",
|
||||
"pass_rate",
|
||||
]
|
||||
|
||||
priority_patterns = {
|
||||
'generated': 100,
|
||||
'gen_': 90,
|
||||
'model_': 80,
|
||||
'predicted': 70,
|
||||
'completion': 60,
|
||||
"generated": 100,
|
||||
"gen_": 90,
|
||||
"model_": 80,
|
||||
"predicted": 70,
|
||||
"completion": 60,
|
||||
}
|
||||
|
||||
def has_keyword(col_name, keywords):
|
||||
"""Check if any keyword appears in column name."""
|
||||
col_lower = col_name.lower()
|
||||
col_normalized = col_lower.replace('_', '').replace('-', '').replace(' ', '')
|
||||
col_normalized = col_lower.replace("_", "").replace("-", "").replace(" ", "")
|
||||
|
||||
for keyword in keywords:
|
||||
if keyword in col_lower or keyword in col_normalized:
|
||||
|
|
@ -180,13 +220,16 @@ def detect_custom_format_heuristic(dataset):
|
|||
return True
|
||||
|
||||
for pattern in metadata_prefix_patterns:
|
||||
if col_lower.startswith(pattern.split('_')[0] + '_') and col_lower != pattern:
|
||||
if '_' in col_lower:
|
||||
prefix = col_lower.split('_')[0]
|
||||
if prefix in ['generation', 'pass', 'inference']:
|
||||
if (
|
||||
col_lower.startswith(pattern.split("_")[0] + "_")
|
||||
and col_lower != pattern
|
||||
):
|
||||
if "_" in col_lower:
|
||||
prefix = col_lower.split("_")[0]
|
||||
if prefix in ["generation", "pass", "inference"]:
|
||||
return True
|
||||
|
||||
if len(col_lower) <= 2 and not col_lower in ['qa', 'q', 'a']:
|
||||
if len(col_lower) <= 2 and not col_lower in ["qa", "q", "a"]:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
|
@ -221,16 +264,18 @@ def detect_custom_format_heuristic(dataset):
|
|||
score += 10
|
||||
|
||||
# Penalize ambiguous keywords when scoring for user
|
||||
if role_type == 'user':
|
||||
if role_type == "user":
|
||||
col_lower = col_name.lower()
|
||||
# If column is ONLY "task" (or task_xxx), give it lower priority for user role
|
||||
if 'task' in col_lower and not any(kw in col_lower for kw in user_words_high_priority):
|
||||
if "task" in col_lower and not any(
|
||||
kw in col_lower for kw in user_words_high_priority
|
||||
):
|
||||
score -= 15 # Significant penalty so other user columns win
|
||||
|
||||
priority_bonus = get_priority_score(col_name)
|
||||
score += priority_bonus
|
||||
|
||||
if role_type in ['assistant', 'user']:
|
||||
if role_type in ["assistant", "user"]:
|
||||
avg_length = get_content_length(col_name)
|
||||
|
||||
if num_candidates > 1:
|
||||
|
|
@ -256,20 +301,24 @@ def detect_custom_format_heuristic(dataset):
|
|||
content_columns = [col for col in all_columns if not is_metadata(col)]
|
||||
|
||||
# Count candidates first
|
||||
assistant_potential = [col for col in content_columns if has_keyword(col, assistant_words)]
|
||||
assistant_potential = [
|
||||
col for col in content_columns if has_keyword(col, assistant_words)
|
||||
]
|
||||
user_potential = [col for col in content_columns if has_keyword(col, user_words)]
|
||||
|
||||
# STEP 1: Find best ASSISTANT column
|
||||
assistant_candidates = []
|
||||
for col in assistant_potential:
|
||||
score = score_column(col, assistant_words, 'assistant', len(assistant_potential))
|
||||
score = score_column(
|
||||
col, assistant_words, "assistant", len(assistant_potential)
|
||||
)
|
||||
if score > 0:
|
||||
assistant_candidates.append((col, score))
|
||||
|
||||
if assistant_candidates:
|
||||
assistant_candidates.sort(key=lambda x: x[1], reverse=True)
|
||||
assistant_candidates.sort(key = lambda x: x[1], reverse = True)
|
||||
assistant_col = assistant_candidates[0][0]
|
||||
mapping[assistant_col] = 'assistant'
|
||||
mapping[assistant_col] = "assistant"
|
||||
else:
|
||||
assistant_col = None
|
||||
|
||||
|
|
@ -278,14 +327,14 @@ def detect_custom_format_heuristic(dataset):
|
|||
for col in user_potential:
|
||||
if col == assistant_col:
|
||||
continue
|
||||
score = score_column(col, user_words, 'user', len(user_potential))
|
||||
score = score_column(col, user_words, "user", len(user_potential))
|
||||
if score > 0:
|
||||
user_candidates.append((col, score))
|
||||
|
||||
if user_candidates:
|
||||
user_candidates.sort(key=lambda x: x[1], reverse=True)
|
||||
user_candidates.sort(key = lambda x: x[1], reverse = True)
|
||||
user_col = user_candidates[0][0]
|
||||
mapping[user_col] = 'user'
|
||||
mapping[user_col] = "user"
|
||||
else:
|
||||
user_col = None
|
||||
|
||||
|
|
@ -296,7 +345,7 @@ def detect_custom_format_heuristic(dataset):
|
|||
for col in remaining_columns:
|
||||
if has_keyword(col, system_words):
|
||||
# Found a system match in remaining columns
|
||||
mapping[col] = 'system'
|
||||
mapping[col] = "system"
|
||||
system_col = col
|
||||
break
|
||||
|
||||
|
|
@ -309,22 +358,22 @@ def detect_custom_format_heuristic(dataset):
|
|||
|
||||
# If no strong keyword match, decide based on what's missing
|
||||
if not has_keyword(remaining_col, user_words + assistant_words):
|
||||
mapping[remaining_col] = 'system'
|
||||
mapping[remaining_col] = "system"
|
||||
elif user_col is None:
|
||||
# No user column yet, assign this as user
|
||||
mapping[remaining_col] = 'user'
|
||||
mapping[remaining_col] = "user"
|
||||
else:
|
||||
# Already have user + assistant, treat as system context
|
||||
mapping[remaining_col] = 'system'
|
||||
mapping[remaining_col] = "system"
|
||||
|
||||
# VALIDATION: Ensure we have at least user + assistant
|
||||
has_user = any(role == 'user' for role in mapping.values())
|
||||
has_assistant = any(role == 'assistant' for role in mapping.values())
|
||||
has_user = any(role == "user" for role in mapping.values())
|
||||
has_assistant = any(role == "assistant" for role in mapping.values())
|
||||
|
||||
if not has_user and len(remaining_columns) > 0:
|
||||
for col in remaining_columns:
|
||||
if col not in mapping:
|
||||
mapping[col] = 'user'
|
||||
mapping[col] = "user"
|
||||
has_user = True
|
||||
break
|
||||
|
||||
|
|
@ -358,14 +407,27 @@ def detect_multimodal_dataset(dataset):
|
|||
|
||||
# Keywords that indicate image data
|
||||
image_keywords = [
|
||||
'image', 'img', 'pixel',
|
||||
'jpg', 'jpeg', 'png', 'webp', 'bmp', 'gif', 'tiff', 'svg',
|
||||
'photo', 'pic', 'picture', 'visual',
|
||||
'file_name', 'filename',
|
||||
"image",
|
||||
"img",
|
||||
"pixel",
|
||||
"jpg",
|
||||
"jpeg",
|
||||
"png",
|
||||
"webp",
|
||||
"bmp",
|
||||
"gif",
|
||||
"tiff",
|
||||
"svg",
|
||||
"photo",
|
||||
"pic",
|
||||
"picture",
|
||||
"visual",
|
||||
"file_name",
|
||||
"filename",
|
||||
]
|
||||
|
||||
# Keywords that indicate audio data
|
||||
audio_keywords = ['audio', 'speech', 'wav', 'waveform', 'sound']
|
||||
audio_keywords = ["audio", "speech", "wav", "waveform", "sound"]
|
||||
|
||||
multimodal_columns = []
|
||||
audio_columns = []
|
||||
|
|
@ -419,7 +481,7 @@ def detect_multimodal_dataset(dataset):
|
|||
# Detect text column for audio datasets
|
||||
detected_text_col = None
|
||||
if audio_columns:
|
||||
text_keywords = ['text', 'sentence', 'transcript', 'transcription', 'label']
|
||||
text_keywords = ["text", "sentence", "transcript", "transcription", "label"]
|
||||
for col_name in column_names:
|
||||
if col_name.lower() in text_keywords:
|
||||
detected_text_col = col_name
|
||||
|
|
@ -430,7 +492,7 @@ def detect_multimodal_dataset(dataset):
|
|||
# Detect speaker_id column for TTS datasets (CSM, Orpheus, Spark)
|
||||
detected_speaker_col = None
|
||||
if audio_columns:
|
||||
speaker_keywords = ['source', 'speaker', 'speaker_id']
|
||||
speaker_keywords = ["source", "speaker", "speaker_id"]
|
||||
for col_name in column_names:
|
||||
if col_name.lower() in speaker_keywords:
|
||||
detected_speaker_col = col_name
|
||||
|
|
@ -456,6 +518,7 @@ def _is_image_value(value) -> bool:
|
|||
# PIL Image instance
|
||||
try:
|
||||
from PIL.Image import Image as PILImage
|
||||
|
||||
if isinstance(value, PILImage):
|
||||
return True
|
||||
except ImportError:
|
||||
|
|
@ -470,7 +533,9 @@ def _is_image_value(value) -> bool:
|
|||
if "bytes" in value and "path" in value:
|
||||
# Check path extension to exclude audio files
|
||||
path = value.get("path") or ""
|
||||
if isinstance(path, str) and any(path.lower().endswith(ext) for ext in _AUDIO_EXTENSIONS):
|
||||
if isinstance(path, str) and any(
|
||||
path.lower().endswith(ext) for ext in _AUDIO_EXTENSIONS
|
||||
):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
|
@ -479,11 +544,13 @@ def _is_image_value(value) -> bool:
|
|||
return _has_image_header(value)
|
||||
|
||||
# String that looks like an image file path or URL
|
||||
_IMAGE_EXTS = ('.png', '.jpg', '.jpeg', '.webp', '.gif', '.bmp', '.tiff', '.svg')
|
||||
_IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".tiff", ".svg")
|
||||
if isinstance(value, str) and len(value) < 1000:
|
||||
lower = value.strip().lower()
|
||||
# Image URL (http://... ending in image extension)
|
||||
if lower.startswith(("http://", "https://")) and any(lower.split("?")[0].endswith(ext) for ext in _IMAGE_EXTS):
|
||||
if lower.startswith(("http://", "https://")) and any(
|
||||
lower.split("?")[0].endswith(ext) for ext in _IMAGE_EXTS
|
||||
):
|
||||
return True
|
||||
# Image file path (relative or absolute path ending in image extension)
|
||||
if any(lower.endswith(ext) for ext in _IMAGE_EXTS):
|
||||
|
|
@ -493,7 +560,15 @@ def _is_image_value(value) -> bool:
|
|||
|
||||
|
||||
_AUDIO_EXTENSIONS = (
|
||||
".wav", ".mp3", ".flac", ".ogg", ".opus", ".m4a", ".aac", ".wma", ".webm",
|
||||
".wav",
|
||||
".mp3",
|
||||
".flac",
|
||||
".ogg",
|
||||
".opus",
|
||||
".m4a",
|
||||
".aac",
|
||||
".wma",
|
||||
".webm",
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -509,7 +584,9 @@ def _is_audio_value(value) -> bool:
|
|||
# Undecoded/streaming → {"bytes": b"...", "path": "some.wav"}
|
||||
if "bytes" in value or "path" in value:
|
||||
path = value.get("path") or ""
|
||||
if isinstance(path, str) and any(path.lower().endswith(ext) for ext in _AUDIO_EXTENSIONS):
|
||||
if isinstance(path, str) and any(
|
||||
path.lower().endswith(ext) for ext in _AUDIO_EXTENSIONS
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
|
@ -520,19 +597,19 @@ def _has_image_header(data: bytes) -> bool:
|
|||
if len(data) < 4:
|
||||
return False
|
||||
# JPEG
|
||||
if data[:2] == b'\xff\xd8':
|
||||
if data[:2] == b"\xff\xd8":
|
||||
return True
|
||||
# PNG
|
||||
if data[:4] == b'\x89PNG':
|
||||
if data[:4] == b"\x89PNG":
|
||||
return True
|
||||
# GIF
|
||||
if data[:3] == b'GIF':
|
||||
if data[:3] == b"GIF":
|
||||
return True
|
||||
# WebP
|
||||
if data[:4] == b'RIFF' and len(data) >= 12 and data[8:12] == b'WEBP':
|
||||
if data[:4] == b"RIFF" and len(data) >= 12 and data[8:12] == b"WEBP":
|
||||
return True
|
||||
# BMP
|
||||
if data[:2] == b'BM':
|
||||
if data[:2] == b"BM":
|
||||
return True
|
||||
return False
|
||||
|
||||
|
|
@ -568,10 +645,13 @@ def detect_vlm_dataset_structure(dataset):
|
|||
|
||||
if isinstance(content, list) and len(content) > 0:
|
||||
if isinstance(content[0], dict) and "type" in content[0]:
|
||||
|
||||
# Check for llava format
|
||||
has_index = any('index' in item for item in content if isinstance(item, dict))
|
||||
has_images_column = 'images' in column_names
|
||||
has_index = any(
|
||||
"index" in item
|
||||
for item in content
|
||||
if isinstance(item, dict)
|
||||
)
|
||||
has_images_column = "images" in column_names
|
||||
|
||||
if has_index and has_images_column:
|
||||
return {
|
||||
|
|
@ -583,7 +663,11 @@ def detect_vlm_dataset_structure(dataset):
|
|||
}
|
||||
|
||||
# Standard VLM format
|
||||
has_image = any('image' in item for item in content if isinstance(item, dict))
|
||||
has_image = any(
|
||||
"image" in item
|
||||
for item in content
|
||||
if isinstance(item, dict)
|
||||
)
|
||||
if has_image:
|
||||
return {
|
||||
"format": "vlm_messages",
|
||||
|
|
@ -637,26 +721,65 @@ def detect_vlm_dataset_structure(dataset):
|
|||
|
||||
# Define metadata patterns to EXCLUDE
|
||||
metadata_patterns = {
|
||||
'suffixes': ['_id', '_url', '_name', '_filename', '_uri', '_link', '_key', '_index'],
|
||||
'prefixes': ['id_', 'url_', 'name_', 'filename_', 'uri_', 'link_', 'key_', 'index_'],
|
||||
"suffixes": [
|
||||
"_id",
|
||||
"_url",
|
||||
"_name",
|
||||
"_filename",
|
||||
"_uri",
|
||||
"_link",
|
||||
"_key",
|
||||
"_index",
|
||||
],
|
||||
"prefixes": [
|
||||
"id_",
|
||||
"url_",
|
||||
"name_",
|
||||
"filename_",
|
||||
"uri_",
|
||||
"link_",
|
||||
"key_",
|
||||
"index_",
|
||||
],
|
||||
}
|
||||
|
||||
# Image-related keywords
|
||||
image_keywords = ['image', 'img', 'photo', 'picture', 'pic', 'visual', 'scan', 'file_name', 'filename']
|
||||
image_keywords = [
|
||||
"image",
|
||||
"img",
|
||||
"photo",
|
||||
"picture",
|
||||
"pic",
|
||||
"visual",
|
||||
"scan",
|
||||
"file_name",
|
||||
"filename",
|
||||
]
|
||||
|
||||
# Text-related keywords
|
||||
text_keywords = ['text', 'caption', 'captions', 'description', 'answer', 'output', 'response', 'label']
|
||||
text_keywords = [
|
||||
"text",
|
||||
"caption",
|
||||
"captions",
|
||||
"description",
|
||||
"answer",
|
||||
"output",
|
||||
"response",
|
||||
"label",
|
||||
]
|
||||
|
||||
def is_metadata_column(col_name):
|
||||
"""Check if column name looks like metadata."""
|
||||
col_lower = col_name.lower()
|
||||
|
||||
# Check suffixes
|
||||
if any(col_lower.endswith(suffix) for suffix in metadata_patterns['suffixes']):
|
||||
if any(col_lower.endswith(suffix) for suffix in metadata_patterns["suffixes"]):
|
||||
return True
|
||||
|
||||
# Check prefixes
|
||||
if any(col_lower.startswith(prefix) for prefix in metadata_patterns['prefixes']):
|
||||
if any(
|
||||
col_lower.startswith(prefix) for prefix in metadata_patterns["prefixes"]
|
||||
):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
|
@ -664,11 +787,13 @@ def detect_vlm_dataset_structure(dataset):
|
|||
def _score_image_candidate(col, sample_value):
|
||||
"""Score a candidate image column by how resolvable its value is."""
|
||||
# PIL Image object (highest priority - already loaded)
|
||||
if hasattr(sample_value, 'size') and hasattr(sample_value, 'mode'):
|
||||
if hasattr(sample_value, "size") and hasattr(sample_value, "mode"):
|
||||
return 100
|
||||
|
||||
# Dict with image data (bytes/path from HF Image feature)
|
||||
if isinstance(sample_value, dict) and ('bytes' in sample_value or 'path' in sample_value):
|
||||
if isinstance(sample_value, dict) and (
|
||||
"bytes" in sample_value or "path" in sample_value
|
||||
):
|
||||
return 75
|
||||
|
||||
if isinstance(sample_value, str):
|
||||
|
|
@ -693,13 +818,16 @@ def detect_vlm_dataset_structure(dataset):
|
|||
|
||||
# Local file — check it exists
|
||||
if not sample_value.startswith(("http://", "https://")):
|
||||
return os.path.exists(sample_value) # bare filenames return False here, that's OK
|
||||
return os.path.exists(
|
||||
sample_value
|
||||
) # bare filenames return False here, that's OK
|
||||
|
||||
# URL — quick HEAD request with short timeout
|
||||
try:
|
||||
import urllib.request
|
||||
req = urllib.request.Request(sample_value, method="HEAD")
|
||||
resp = urllib.request.urlopen(req, timeout=3)
|
||||
|
||||
req = urllib.request.Request(sample_value, method = "HEAD")
|
||||
resp = urllib.request.urlopen(req, timeout = 3)
|
||||
return resp.status < 400
|
||||
except Exception:
|
||||
return False
|
||||
|
|
@ -732,7 +860,7 @@ def detect_vlm_dataset_structure(dataset):
|
|||
if not candidates:
|
||||
return None
|
||||
|
||||
candidates.sort(key=lambda x: x[1], reverse=True)
|
||||
candidates.sort(key = lambda x: x[1], reverse = True)
|
||||
|
||||
# Single candidate or top candidate is PIL/dict — no probing needed
|
||||
if len(candidates) == 1 or candidates[0][1] >= 75:
|
||||
|
|
@ -766,14 +894,18 @@ def detect_vlm_dataset_structure(dataset):
|
|||
# Longer text = higher priority (likely content, not just a label)
|
||||
priority = min(len(sample_value), 1000) # Cap at 1000
|
||||
candidates.append((col, priority))
|
||||
elif isinstance(sample_value, list) and len(sample_value) > 0 and isinstance(sample_value[0], str):
|
||||
elif (
|
||||
isinstance(sample_value, list)
|
||||
and len(sample_value) > 0
|
||||
and isinstance(sample_value[0], str)
|
||||
):
|
||||
# List of strings (e.g. captions list) — lower priority than plain strings
|
||||
priority = min(len(sample_value[0]), 1000) // 2
|
||||
candidates.append((col, priority))
|
||||
|
||||
# Return highest priority candidate
|
||||
if candidates:
|
||||
candidates.sort(key=lambda x: x[1], reverse=True)
|
||||
candidates.sort(key = lambda x: x[1], reverse = True)
|
||||
return candidates[0][0]
|
||||
|
||||
return None
|
||||
|
|
|
|||
|
|
@ -42,33 +42,45 @@ def precache_helper_gguf():
|
|||
return
|
||||
|
||||
repo = os.environ.get("UNSLOTH_HELPER_MODEL_REPO", DEFAULT_HELPER_MODEL_REPO)
|
||||
variant = os.environ.get("UNSLOTH_HELPER_MODEL_VARIANT", DEFAULT_HELPER_MODEL_VARIANT)
|
||||
variant = os.environ.get(
|
||||
"UNSLOTH_HELPER_MODEL_VARIANT", DEFAULT_HELPER_MODEL_VARIANT
|
||||
)
|
||||
|
||||
try:
|
||||
from huggingface_hub import HfApi, hf_hub_download
|
||||
from huggingface_hub.utils import disable_progress_bars, enable_progress_bars
|
||||
|
||||
disable_progress_bars()
|
||||
logging.getLogger("huggingface_hub").setLevel(logging.WARNING)
|
||||
|
||||
# Find the GGUF file matching the variant
|
||||
api = HfApi()
|
||||
files = api.list_repo_files(repo, repo_type="model")
|
||||
files = api.list_repo_files(repo, repo_type = "model")
|
||||
gguf_files = [f for f in files if f.endswith(".gguf")]
|
||||
|
||||
# Find all GGUF files matching the variant (may be split into shards)
|
||||
variant_lower = variant.lower().replace("-", "_")
|
||||
matching = sorted(
|
||||
f for f in gguf_files
|
||||
if variant_lower in f.lower().replace("-", "_")
|
||||
f for f in gguf_files if variant_lower in f.lower().replace("-", "_")
|
||||
)
|
||||
|
||||
if matching:
|
||||
logger.info(f"Pre-caching helper GGUF: {repo}/{matching[0]}"
|
||||
+ (f" (+{len(matching) - 1} shards)" if len(matching) > 1 else ""))
|
||||
logger.info(
|
||||
f"Pre-caching helper GGUF: {repo}/{matching[0]}"
|
||||
+ (f" (+{len(matching) - 1} shards)" if len(matching) > 1 else "")
|
||||
)
|
||||
for target in matching:
|
||||
hf_hub_download(repo_id=repo, filename=target)
|
||||
hf_hub_download(repo_id = repo, filename = target)
|
||||
logger.info(f"Helper GGUF cached: {len(matching)} file(s)")
|
||||
else:
|
||||
logger.warning(f"No GGUF matching variant '{variant}' in {repo}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to pre-cache helper GGUF: {e}")
|
||||
finally:
|
||||
try:
|
||||
enable_progress_bars()
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
|
||||
def _run_with_helper(prompt: str, max_tokens: int = 256) -> Optional[str]:
|
||||
|
|
@ -81,7 +93,9 @@ def _run_with_helper(prompt: str, max_tokens: int = 256) -> Optional[str]:
|
|||
return None
|
||||
|
||||
repo = os.environ.get("UNSLOTH_HELPER_MODEL_REPO", DEFAULT_HELPER_MODEL_REPO)
|
||||
variant = os.environ.get("UNSLOTH_HELPER_MODEL_VARIANT", DEFAULT_HELPER_MODEL_VARIANT)
|
||||
variant = os.environ.get(
|
||||
"UNSLOTH_HELPER_MODEL_VARIANT", DEFAULT_HELPER_MODEL_VARIANT
|
||||
)
|
||||
|
||||
backend = None
|
||||
try:
|
||||
|
|
@ -89,15 +103,14 @@ def _run_with_helper(prompt: str, max_tokens: int = 256) -> Optional[str]:
|
|||
|
||||
backend = LlamaCppBackend()
|
||||
logger.info(f"Loading helper model: {repo} ({variant})")
|
||||
print(f"🤖 Loading helper model: {repo} ({variant})...")
|
||||
|
||||
ok = backend.load_model(
|
||||
hf_repo=repo,
|
||||
hf_variant=variant,
|
||||
model_identifier=f"helper:{repo}:{variant}",
|
||||
is_vision=False,
|
||||
n_ctx=2048,
|
||||
n_gpu_layers=-1,
|
||||
hf_repo = repo,
|
||||
hf_variant = variant,
|
||||
model_identifier = f"helper:{repo}:{variant}",
|
||||
is_vision = False,
|
||||
n_ctx = 2048,
|
||||
n_gpu_layers = -1,
|
||||
)
|
||||
if not ok:
|
||||
logger.warning("Helper model failed to start")
|
||||
|
|
@ -106,12 +119,12 @@ def _run_with_helper(prompt: str, max_tokens: int = 256) -> Optional[str]:
|
|||
messages = [{"role": "user", "content": prompt}]
|
||||
cumulative = ""
|
||||
for text in backend.generate_chat_completion(
|
||||
messages=messages,
|
||||
temperature=0.1,
|
||||
top_p=0.9,
|
||||
top_k=20,
|
||||
max_tokens=max_tokens,
|
||||
repetition_penalty=1.0,
|
||||
messages = messages,
|
||||
temperature = 0.1,
|
||||
top_p = 0.9,
|
||||
top_k = 20,
|
||||
max_tokens = max_tokens,
|
||||
repetition_penalty = 1.0,
|
||||
):
|
||||
cumulative = text # cumulative — last value is full text
|
||||
|
||||
|
|
@ -127,7 +140,7 @@ def _run_with_helper(prompt: str, max_tokens: int = 256) -> Optional[str]:
|
|||
if backend is not None:
|
||||
try:
|
||||
backend.unload_model()
|
||||
print("🤖 Helper model unloaded")
|
||||
logger.info("Helper model unloaded")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
@ -176,7 +189,7 @@ def llm_generate_vlm_instruction(
|
|||
"Respond with ONLY the instruction sentence, nothing else."
|
||||
)
|
||||
|
||||
result = _run_with_helper(prompt, max_tokens=100)
|
||||
result = _run_with_helper(prompt, max_tokens = 100)
|
||||
if not result:
|
||||
return None
|
||||
|
||||
|
|
@ -187,7 +200,7 @@ def llm_generate_vlm_instruction(
|
|||
logger.warning(f"Helper model returned unusable instruction: {instruction!r}")
|
||||
return None
|
||||
|
||||
print(f"🤖 LLM-generated instruction: {instruction}")
|
||||
logger.info(f"LLM-generated instruction: {instruction}")
|
||||
return {
|
||||
"instruction": instruction,
|
||||
"confidence": 0.85,
|
||||
|
|
@ -231,7 +244,7 @@ def llm_classify_columns(
|
|||
'Example: {"question": "user", "answer": "assistant", "id": "metadata"}'
|
||||
)
|
||||
|
||||
result = _run_with_helper(prompt, max_tokens=200)
|
||||
result = _run_with_helper(prompt, max_tokens = 200)
|
||||
if not result:
|
||||
return None
|
||||
|
||||
|
|
@ -248,6 +261,7 @@ def llm_classify_columns(
|
|||
except json.JSONDecodeError:
|
||||
# Try to find JSON object in the response
|
||||
import re
|
||||
|
||||
match = re.search(r"\{[^}]+\}", text)
|
||||
if match:
|
||||
try:
|
||||
|
|
@ -266,7 +280,11 @@ def llm_classify_columns(
|
|||
valid_roles = {"user", "assistant", "system", "metadata"}
|
||||
cleaned = {}
|
||||
for col, role in mapping.items():
|
||||
if col in column_names and isinstance(role, str) and role.lower() in valid_roles:
|
||||
if (
|
||||
col in column_names
|
||||
and isinstance(role, str)
|
||||
and role.lower() in valid_roles
|
||||
):
|
||||
cleaned[col] = role.lower()
|
||||
|
||||
if not cleaned:
|
||||
|
|
@ -278,7 +296,7 @@ def llm_classify_columns(
|
|||
logger.warning(f"Helper model mapping missing user/assistant: {cleaned}")
|
||||
return None
|
||||
|
||||
print(f"🤖 LLM-classified columns: {cleaned}")
|
||||
logger.info(f"LLM-classified columns: {cleaned}")
|
||||
return cleaned
|
||||
|
||||
|
||||
|
|
@ -319,7 +337,7 @@ def llm_generate_dataset_warning(
|
|||
"Keep it under 3 sentences. Be specific about the dataset."
|
||||
)
|
||||
|
||||
result = _run_with_helper(prompt, max_tokens=200)
|
||||
result = _run_with_helper(prompt, max_tokens = 200)
|
||||
if not result:
|
||||
return None
|
||||
|
||||
|
|
@ -328,7 +346,7 @@ def llm_generate_dataset_warning(
|
|||
if len(warning) < 10 or len(warning) > 500:
|
||||
return None
|
||||
|
||||
print(f"🤖 LLM-generated warning: {warning}")
|
||||
logger.info(f"LLM-generated warning: {warning}")
|
||||
return warning
|
||||
|
||||
|
||||
|
|
@ -369,18 +387,16 @@ def _parse_json_response(text: str) -> Optional[dict]:
|
|||
return None
|
||||
|
||||
|
||||
def _generate_with_backend(
|
||||
backend, messages: list[dict], max_tokens: int = 512
|
||||
) -> str:
|
||||
def _generate_with_backend(backend, messages: list[dict], max_tokens: int = 512) -> str:
|
||||
"""Run one chat completion on an already-loaded backend. Returns raw text."""
|
||||
cumulative = ""
|
||||
for text in backend.generate_chat_completion(
|
||||
messages=messages,
|
||||
temperature=0.1,
|
||||
top_p=0.9,
|
||||
top_k=20,
|
||||
max_tokens=max_tokens,
|
||||
repetition_penalty=1.0,
|
||||
messages = messages,
|
||||
temperature = 0.1,
|
||||
top_p = 0.9,
|
||||
top_k = 20,
|
||||
max_tokens = max_tokens,
|
||||
repetition_penalty = 1.0,
|
||||
):
|
||||
cumulative = text
|
||||
return cumulative.strip()
|
||||
|
|
@ -398,7 +414,7 @@ def fetch_hf_dataset_card(
|
|||
try:
|
||||
from huggingface_hub import DatasetCard
|
||||
|
||||
card = DatasetCard.load(dataset_name, token=hf_token)
|
||||
card = DatasetCard.load(dataset_name, token = hf_token)
|
||||
readme = card.text or ""
|
||||
|
||||
# Truncate at sentence boundary
|
||||
|
|
@ -413,14 +429,21 @@ def fetch_hf_dataset_card(
|
|||
metadata = {}
|
||||
if card.data:
|
||||
for key in (
|
||||
"task_categories", "task_ids", "language",
|
||||
"size_categories", "tags", "license", "pretty_name",
|
||||
"task_categories",
|
||||
"task_ids",
|
||||
"language",
|
||||
"size_categories",
|
||||
"tags",
|
||||
"license",
|
||||
"pretty_name",
|
||||
):
|
||||
val = getattr(card.data, key, None)
|
||||
if val is not None:
|
||||
metadata[key] = val
|
||||
|
||||
logger.info(f"Fetched dataset card: {len(readme)} chars, {len(metadata)} metadata fields")
|
||||
logger.info(
|
||||
f"Fetched dataset card: {len(readme)} chars, {len(metadata)} metadata fields"
|
||||
)
|
||||
return readme, metadata
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -447,30 +470,31 @@ def _run_multi_pass_advisor(
|
|||
return None
|
||||
|
||||
repo = os.environ.get("UNSLOTH_HELPER_MODEL_REPO", DEFAULT_HELPER_MODEL_REPO)
|
||||
variant = os.environ.get("UNSLOTH_HELPER_MODEL_VARIANT", DEFAULT_HELPER_MODEL_VARIANT)
|
||||
variant = os.environ.get(
|
||||
"UNSLOTH_HELPER_MODEL_VARIANT", DEFAULT_HELPER_MODEL_VARIANT
|
||||
)
|
||||
|
||||
backend = None
|
||||
try:
|
||||
from core.inference.llama_cpp import LlamaCppBackend
|
||||
|
||||
backend = LlamaCppBackend()
|
||||
print(f"🤖 Loading advisor model: {repo} ({variant})...")
|
||||
logger.info(f"Loading advisor model: {repo} ({variant})")
|
||||
t0 = time.monotonic()
|
||||
|
||||
ok = backend.load_model(
|
||||
hf_repo=repo,
|
||||
hf_variant=variant,
|
||||
model_identifier=f"advisor:{repo}:{variant}",
|
||||
is_vision=False,
|
||||
n_ctx=2048,
|
||||
n_gpu_layers=-1,
|
||||
hf_repo = repo,
|
||||
hf_variant = variant,
|
||||
model_identifier = f"advisor:{repo}:{variant}",
|
||||
is_vision = False,
|
||||
n_ctx = 2048,
|
||||
n_gpu_layers = -1,
|
||||
)
|
||||
if not ok:
|
||||
logger.warning("Advisor model failed to start")
|
||||
return None
|
||||
|
||||
print(f"🤖 Advisor model loaded in {time.monotonic() - t0:.1f}s")
|
||||
|
||||
logger.info(f"Advisor model loaded in {time.monotonic() - t0:.1f}s")
|
||||
# ── Format samples ──
|
||||
samples_text = ""
|
||||
for i, row in enumerate(samples[:5], 1):
|
||||
|
|
@ -478,8 +502,9 @@ def _run_multi_pass_advisor(
|
|||
samples_text += f"Row {i}:\n" + "\n".join(parts) + "\n"
|
||||
|
||||
metadata_str = (
|
||||
json.dumps(dataset_metadata, indent=2, default=str)[:500]
|
||||
if dataset_metadata else "N/A"
|
||||
json.dumps(dataset_metadata, indent = 2, default = str)[:500]
|
||||
if dataset_metadata
|
||||
else "N/A"
|
||||
)
|
||||
card_excerpt = (dataset_card or "")[:1200] or "N/A"
|
||||
|
||||
|
|
@ -489,13 +514,14 @@ def _run_multi_pass_advisor(
|
|||
if model_name:
|
||||
try:
|
||||
from utils.models.model_config import load_model_config
|
||||
config = load_model_config(model_name, use_auth=True, token=hf_token)
|
||||
|
||||
config = load_model_config(model_name, use_auth = True, token = hf_token)
|
||||
archs = getattr(config, "architectures", [])
|
||||
if archs and "Gemma3nForConditionalGeneration" in archs:
|
||||
is_gemma_3n = True
|
||||
except Exception:
|
||||
is_gemma_3n = "gemma-3n" in model_name.lower()
|
||||
|
||||
|
||||
if model_type == "audio" and not is_gemma_3n:
|
||||
target_hints = (
|
||||
"\n\nHINT: The user is training an AUDIO model. The dataset MUST contain "
|
||||
|
|
@ -514,7 +540,7 @@ def _run_multi_pass_advisor(
|
|||
)
|
||||
|
||||
# ── Pass 1: Classify ──
|
||||
print("🤖 Pass 1: Classifying dataset...", flush=True)
|
||||
logger.info("Pass 1: Classifying dataset...")
|
||||
t1 = time.monotonic()
|
||||
messages1 = [
|
||||
{
|
||||
|
|
@ -559,9 +585,9 @@ def _run_multi_pass_advisor(
|
|||
Respond with ONLY the JSON object. No markdown, no explanation."""),
|
||||
},
|
||||
]
|
||||
raw1 = _generate_with_backend(backend, messages1, max_tokens=256)
|
||||
raw1 = _generate_with_backend(backend, messages1, max_tokens = 256)
|
||||
pass1 = _parse_json_response(raw1)
|
||||
print(f"🤖 Pass 1 done ({time.monotonic() - t1:.1f}s): {pass1}", flush=True)
|
||||
logger.info(f"Pass 1 done ({time.monotonic() - t1:.1f}s): {pass1}")
|
||||
|
||||
if not pass1:
|
||||
logger.warning(f"Advisor Pass 1 failed to produce JSON: {raw1[:200]}")
|
||||
|
|
@ -580,7 +606,9 @@ def _run_multi_pass_advisor(
|
|||
}
|
||||
|
||||
# ── Pass 2: Map columns to roles ──
|
||||
print("🤖 Pass 2: Mapping columns to roles...", flush=True)
|
||||
logger.info("Pass 2: Mapping columns to roles...")
|
||||
|
||||
|
||||
t2 = time.monotonic()
|
||||
messages2 = [
|
||||
{
|
||||
|
|
@ -592,13 +620,13 @@ def _run_multi_pass_advisor(
|
|||
'- "user" = This column contains INPUT that the model will receive as a prompt.\n'
|
||||
'- "assistant" = This column contains OUTPUT that the model should learn to generate.\n\n'
|
||||
"CRITICAL RULES:\n"
|
||||
"1. There MUST be at least one column assigned to \"user\" AND at least one "
|
||||
"column assigned to \"assistant\". Never assign all columns to the same role.\n"
|
||||
'1. There MUST be at least one column assigned to "user" AND at least one '
|
||||
'column assigned to "assistant". Never assign all columns to the same role.\n'
|
||||
"2. The column that contains the TARGET or OUTPUT or ANSWER or LABEL must "
|
||||
"ALWAYS be assigned to \"assistant\". This is the thing the model should learn "
|
||||
'ALWAYS be assigned to "assistant". This is the thing the model should learn '
|
||||
"to produce.\n"
|
||||
"3. The columns that contain the SOURCE or INPUT or CONTEXT or QUESTION must "
|
||||
"be assigned to \"user\". This is what the model receives.\n"
|
||||
'be assigned to "user". This is what the model receives.\n'
|
||||
'4. Metadata columns like "id", "index", "source", "url", "date" should be '
|
||||
'set to "skip".\n\n'
|
||||
"You must respond with ONLY a valid JSON object."
|
||||
|
|
@ -611,7 +639,7 @@ def _run_multi_pass_advisor(
|
|||
Here is a dataset that has been classified:
|
||||
|
||||
CLASSIFICATION:
|
||||
{json.dumps(pass1, indent=2)}
|
||||
{json.dumps(pass1, indent = 2)}
|
||||
|
||||
COLUMNS AVAILABLE: {columns}
|
||||
|
||||
|
|
@ -659,9 +687,9 @@ def _run_multi_pass_advisor(
|
|||
Respond with ONLY the JSON object."""),
|
||||
},
|
||||
]
|
||||
raw2 = _generate_with_backend(backend, messages2, max_tokens=512)
|
||||
raw2 = _generate_with_backend(backend, messages2, max_tokens = 512)
|
||||
pass2 = _parse_json_response(raw2)
|
||||
print(f"🤖 Pass 2 done ({time.monotonic() - t2:.1f}s): {pass2}", flush=True)
|
||||
logger.info(f"Pass 2 done ({time.monotonic() - t2:.1f}s): {pass2}")
|
||||
|
||||
if not pass2:
|
||||
logger.warning(f"Advisor Pass 2 failed to produce JSON: {raw2[:200]}")
|
||||
|
|
@ -674,10 +702,7 @@ def _run_multi_pass_advisor(
|
|||
# Validate: must have at least one user AND one assistant
|
||||
roles_present = set(column_roles.values())
|
||||
if "user" not in roles_present or "assistant" not in roles_present:
|
||||
print(
|
||||
f"🤖 Pass 2 sanity fail: missing user or assistant role: {column_roles}",
|
||||
flush=True,
|
||||
)
|
||||
logger.warning(f"Pass 2 sanity fail: missing user or assistant role: {column_roles}")
|
||||
return None # triggers fallback to simple classification
|
||||
|
||||
# ── Pass 3: System prompt (non-conversational datasets only) ──
|
||||
|
|
@ -686,7 +711,7 @@ def _run_multi_pass_advisor(
|
|||
is_conv = pass1.get("is_conversational", False)
|
||||
|
||||
if not is_conv:
|
||||
print("🤖 Pass 3: Generating system prompt...", flush=True)
|
||||
logger.info("Pass 3: Generating system prompt...")
|
||||
t3 = time.monotonic()
|
||||
|
||||
# Format label mapping info for the prompt
|
||||
|
|
@ -726,8 +751,8 @@ def _run_multi_pass_advisor(
|
|||
Write ONLY the system prompt text. No quotes, no labels, no explanation around it."""),
|
||||
},
|
||||
]
|
||||
raw3 = _generate_with_backend(backend, messages3, max_tokens=256)
|
||||
print(f"🤖 Pass 3 done ({time.monotonic() - t3:.1f}s): {raw3[:200] if raw3 else None}", flush=True)
|
||||
raw3 = _generate_with_backend(backend, messages3, max_tokens = 256)
|
||||
logger.info(f"Pass 3 done ({time.monotonic() - t3:.1f}s): {raw3[:200] if raw3 else None}")
|
||||
|
||||
if raw3:
|
||||
# Pass 3 returns raw text, not JSON — clean it up
|
||||
|
|
@ -746,15 +771,14 @@ def _run_multi_pass_advisor(
|
|||
note_parts = [f"This is a {dtype} dataset (not conversational)."]
|
||||
if desc:
|
||||
note_parts.append(desc)
|
||||
note_parts.append("Columns have been mapped to conversation roles. You can adjust the mapping if needed.")
|
||||
note_parts.append(
|
||||
"Columns have been mapped to conversation roles. You can adjust the mapping if needed."
|
||||
)
|
||||
user_notification = " ".join(note_parts)
|
||||
|
||||
total_time = time.monotonic() - t0
|
||||
print(
|
||||
f"🤖 Advisor complete ({total_time:.1f}s): type={dtype}, "
|
||||
f"mapping={suggested_mapping}, sys_prompt={bool(sys_prompt)}, label_map={bool(label_map)}",
|
||||
flush=True,
|
||||
)
|
||||
logger.info(f"Advisor complete ({total_time:.1f}s): type={dtype}, mapping={suggested_mapping}, sys_prompt={bool(sys_prompt)}, label_map={bool(label_map)}")
|
||||
|
||||
|
||||
return {
|
||||
"success": True,
|
||||
|
|
@ -774,7 +798,7 @@ def _run_multi_pass_advisor(
|
|||
if backend is not None:
|
||||
try:
|
||||
backend.unload_model()
|
||||
print("🤖 Advisor model unloaded")
|
||||
logger.info("Advisor model unloaded")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
@ -805,18 +829,18 @@ def llm_conversion_advisor(
|
|||
|
||||
# Try multi-pass advisor
|
||||
result = _run_multi_pass_advisor(
|
||||
columns=column_names,
|
||||
samples=samples,
|
||||
dataset_name=dataset_name,
|
||||
dataset_card=dataset_card,
|
||||
dataset_metadata=dataset_metadata,
|
||||
model_name=model_name,
|
||||
model_type=model_type,
|
||||
hf_token=hf_token,
|
||||
columns = column_names,
|
||||
samples = samples,
|
||||
dataset_name = dataset_name,
|
||||
dataset_card = dataset_card,
|
||||
dataset_metadata = dataset_metadata,
|
||||
model_name = model_name,
|
||||
model_type = model_type,
|
||||
hf_token = hf_token,
|
||||
)
|
||||
|
||||
if result and result.get("success"):
|
||||
print(f"🤖 Conversion advisor succeeded: type={result.get('dataset_type')}")
|
||||
logger.info(f"Conversion advisor succeeded: type={result.get('dataset_type')}")
|
||||
return result
|
||||
|
||||
# Fallback: simple column classification
|
||||
|
|
@ -826,7 +850,8 @@ def llm_conversion_advisor(
|
|||
return {
|
||||
"success": True,
|
||||
"suggested_mapping": {
|
||||
col: role for col, role in simple_mapping.items()
|
||||
col: role
|
||||
for col, role in simple_mapping.items()
|
||||
if role in ("user", "assistant", "system")
|
||||
},
|
||||
"dataset_type": None,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ This module contains the mapping dictionaries that associate model names
|
|||
with their corresponding chat templates and response markers.
|
||||
"""
|
||||
|
||||
|
||||
TEMPLATE_TO_MODEL_MAPPER = {
|
||||
"phi-3.5": (
|
||||
"unsloth/Phi-3.5-mini-instruct-bnb-4bit",
|
||||
|
|
@ -407,14 +406,11 @@ MODEL_TO_TEMPLATE_MAPPER = {}
|
|||
for key, values in TEMPLATE_TO_MODEL_MAPPER.items():
|
||||
for value in values:
|
||||
MODEL_TO_TEMPLATE_MAPPER[value] = key
|
||||
pass
|
||||
|
||||
# Get lowercased
|
||||
lowered_key = key.lower()
|
||||
for value in values:
|
||||
MODEL_TO_TEMPLATE_MAPPER[value.lower()] = lowered_key
|
||||
pass
|
||||
pass
|
||||
|
||||
|
||||
TEMPLATE_TO_RESPONSES_MAPPER = {
|
||||
|
|
@ -531,4 +527,3 @@ TEMPLATE_TO_RESPONSES_MAPPER = {
|
|||
"response": "<|assistant|><think>",
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,9 +14,9 @@ from itertools import islice
|
|||
|
||||
def generate_smart_vlm_instruction(
|
||||
dataset,
|
||||
text_column="text",
|
||||
image_column="image",
|
||||
dataset_name=None,
|
||||
text_column = "text",
|
||||
image_column = "image",
|
||||
dataset_name = None,
|
||||
):
|
||||
"""
|
||||
Generate smart, context-aware instruction for VLM datasets using heuristics.
|
||||
|
|
@ -66,11 +66,12 @@ def generate_smart_vlm_instruction(
|
|||
# OCR / Transcription
|
||||
"ocr": {
|
||||
"keywords": ["ocr", "transcribe", "transcript"],
|
||||
"content_hints": [r"[A-Za-z\u0600-\u06FF]{10,}"], # Long text passages (Latin/Arabic)
|
||||
"content_hints": [
|
||||
r"[A-Za-z\u0600-\u06FF]{10,}"
|
||||
], # Long text passages (Latin/Arabic)
|
||||
"instruction": "Transcribe all the text shown in this image.",
|
||||
"confidence": 0.9,
|
||||
},
|
||||
|
||||
# LaTeX / Math
|
||||
"latex": {
|
||||
"keywords": ["latex", "math", "formula", "equation"],
|
||||
|
|
@ -78,7 +79,6 @@ def generate_smart_vlm_instruction(
|
|||
"instruction": "Convert this image to LaTeX notation.",
|
||||
"confidence": 0.95,
|
||||
},
|
||||
|
||||
# Caption / Description
|
||||
"caption": {
|
||||
"keywords": ["caption", "description", "describe"],
|
||||
|
|
@ -86,15 +86,21 @@ def generate_smart_vlm_instruction(
|
|||
"instruction": "Provide a detailed description of this image.",
|
||||
"confidence": 0.85,
|
||||
},
|
||||
|
||||
# Medical / Radiology
|
||||
"medical": {
|
||||
"keywords": ["medical", "radiology", "xray", "ct", "mri", "scan", "diagnosis"],
|
||||
"keywords": [
|
||||
"medical",
|
||||
"radiology",
|
||||
"xray",
|
||||
"ct",
|
||||
"mri",
|
||||
"scan",
|
||||
"diagnosis",
|
||||
],
|
||||
"content_hints": [r"\b(lesion|radiograph|patient|diagnosis|findings)\b"],
|
||||
"instruction": "Analyze this medical image and describe the key findings.",
|
||||
"confidence": 0.9,
|
||||
},
|
||||
|
||||
# Code / Programming
|
||||
"code": {
|
||||
"keywords": ["code", "program", "function", "algorithm"],
|
||||
|
|
@ -102,7 +108,6 @@ def generate_smart_vlm_instruction(
|
|||
"instruction": "Explain what this code visualization shows.",
|
||||
"confidence": 0.85,
|
||||
},
|
||||
|
||||
# Chart / Graph
|
||||
"chart": {
|
||||
"keywords": ["chart", "graph", "plot", "visualization", "diagram"],
|
||||
|
|
@ -110,7 +115,6 @@ def generate_smart_vlm_instruction(
|
|||
"instruction": "Describe this chart or graph, including key data points and trends.",
|
||||
"confidence": 0.85,
|
||||
},
|
||||
|
||||
# Document / Text Recognition
|
||||
"document": {
|
||||
"keywords": ["document", "page", "paragraph", "article"],
|
||||
|
|
@ -132,7 +136,9 @@ def generate_smart_vlm_instruction(
|
|||
score += 0.5
|
||||
|
||||
# Check dataset name if provided
|
||||
if dataset_name and any(keyword in dataset_name.lower() for keyword in task_info["keywords"]):
|
||||
if dataset_name and any(
|
||||
keyword in dataset_name.lower() for keyword in task_info["keywords"]
|
||||
):
|
||||
score += 0.3
|
||||
|
||||
# Check content patterns
|
||||
|
|
@ -186,7 +192,7 @@ def generate_smart_vlm_instruction(
|
|||
row = {}
|
||||
for col in s:
|
||||
val = s[col]
|
||||
if hasattr(val, 'size') and hasattr(val, 'mode'): # PIL Image
|
||||
if hasattr(val, "size") and hasattr(val, "mode"): # PIL Image
|
||||
row[col] = "<image>"
|
||||
elif isinstance(val, list):
|
||||
row[col] = str(val)[:300]
|
||||
|
|
@ -195,15 +201,15 @@ def generate_smart_vlm_instruction(
|
|||
sample_rows.append(row)
|
||||
|
||||
llm_result = llm_generate_vlm_instruction(
|
||||
column_names=list(column_names),
|
||||
samples=sample_rows,
|
||||
dataset_name=dataset_name,
|
||||
column_names = list(column_names),
|
||||
samples = sample_rows,
|
||||
dataset_name = dataset_name,
|
||||
)
|
||||
if llm_result and llm_result.get("instruction"):
|
||||
print(
|
||||
f"\n[DEBUG] LLM-assisted VLM instruction generated: "
|
||||
f"'{llm_result['instruction']}' (confidence={llm_result.get('confidence', 'N/A')})\n",
|
||||
flush=True,
|
||||
flush = True,
|
||||
)
|
||||
return {
|
||||
"instruction": llm_result["instruction"],
|
||||
|
|
@ -214,6 +220,7 @@ def generate_smart_vlm_instruction(
|
|||
}
|
||||
except Exception as e:
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).debug(f"LLM-assisted instruction skipped: {e}")
|
||||
|
||||
# ===== LEVEL 5: Generic Fallback =====
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"""
|
||||
Hardware detection and GPU utilities
|
||||
"""
|
||||
|
||||
from .hardware import (
|
||||
DeviceType,
|
||||
DEVICE,
|
||||
|
|
@ -21,17 +22,17 @@ from .hardware import (
|
|||
)
|
||||
|
||||
__all__ = [
|
||||
'DeviceType',
|
||||
'DEVICE',
|
||||
'detect_hardware',
|
||||
'get_device',
|
||||
'is_apple_silicon',
|
||||
'clear_gpu_cache',
|
||||
'get_gpu_memory_info',
|
||||
'log_gpu_memory',
|
||||
'get_gpu_summary',
|
||||
'get_package_versions',
|
||||
'get_gpu_utilization',
|
||||
'get_physical_gpu_count',
|
||||
'safe_num_proc',
|
||||
"DeviceType",
|
||||
"DEVICE",
|
||||
"detect_hardware",
|
||||
"get_device",
|
||||
"is_apple_silicon",
|
||||
"clear_gpu_cache",
|
||||
"get_gpu_memory_info",
|
||||
"log_gpu_memory",
|
||||
"get_gpu_summary",
|
||||
"get_package_versions",
|
||||
"get_gpu_utilization",
|
||||
"get_physical_gpu_count",
|
||||
"safe_num_proc",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ Usage:
|
|||
import torch
|
||||
...
|
||||
"""
|
||||
|
||||
import platform
|
||||
import structlog
|
||||
from loggers import get_logger
|
||||
|
|
@ -26,11 +27,13 @@ logger = get_logger(__name__)
|
|||
|
||||
# ========== Device Enum ==========
|
||||
|
||||
|
||||
class DeviceType(str, Enum):
|
||||
"""Supported compute backends. Inherits from str so it serializes cleanly in JSON."""
|
||||
|
||||
CUDA = "cuda"
|
||||
MLX = "mlx"
|
||||
CPU = "cpu"
|
||||
MLX = "mlx"
|
||||
CPU = "cpu"
|
||||
|
||||
|
||||
# ========== Global State (set once by detect_hardware) ==========
|
||||
|
|
@ -40,6 +43,7 @@ DEVICE: Optional[DeviceType] = None
|
|||
|
||||
# ========== Detection ==========
|
||||
|
||||
|
||||
def is_apple_silicon() -> bool:
|
||||
"""Check if running on Apple Silicon hardware (pure platform check, no ML imports)."""
|
||||
return platform.system() == "Darwin" and platform.machine() == "arm64"
|
||||
|
|
@ -49,6 +53,7 @@ def _has_torch() -> bool:
|
|||
"""Check if PyTorch is importable."""
|
||||
try:
|
||||
import torch
|
||||
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
|
@ -58,6 +63,7 @@ def _has_mlx() -> bool:
|
|||
"""Check if MLX is importable."""
|
||||
try:
|
||||
import mlx.core
|
||||
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
|
@ -80,6 +86,7 @@ def detect_hardware() -> DeviceType:
|
|||
# --- CUDA: try PyTorch ---
|
||||
if _has_torch():
|
||||
import torch
|
||||
|
||||
if torch.cuda.is_available():
|
||||
DEVICE = DeviceType.CUDA
|
||||
device_name = torch.cuda.get_device_properties(0).name
|
||||
|
|
@ -101,6 +108,7 @@ def detect_hardware() -> DeviceType:
|
|||
|
||||
# ========== Convenience helpers ==========
|
||||
|
||||
|
||||
def get_device() -> DeviceType:
|
||||
"""
|
||||
Return the detected device. Auto-detects if detect_hardware() hasn't been called yet.
|
||||
|
|
@ -118,12 +126,14 @@ def clear_gpu_cache():
|
|||
Safe to call on any platform — no-ops gracefully.
|
||||
"""
|
||||
import gc
|
||||
|
||||
gc.collect()
|
||||
|
||||
device = get_device()
|
||||
|
||||
if device == DeviceType.CUDA:
|
||||
import torch
|
||||
|
||||
torch.cuda.synchronize()
|
||||
torch.cuda.empty_cache()
|
||||
torch.cuda.ipc_collect()
|
||||
|
|
@ -144,6 +154,7 @@ def get_gpu_memory_info() -> Dict[str, Any]:
|
|||
if device == DeviceType.CUDA:
|
||||
try:
|
||||
import torch
|
||||
|
||||
idx = torch.cuda.current_device()
|
||||
props = torch.cuda.get_device_properties(idx)
|
||||
|
||||
|
|
@ -215,6 +226,7 @@ def log_gpu_memory(context: str):
|
|||
|
||||
# ========== GPU Summary & Package Versions ==========
|
||||
|
||||
|
||||
def get_gpu_summary() -> Dict[str, Any]:
|
||||
"""
|
||||
Return a compact summary of the primary GPU.
|
||||
|
|
@ -256,6 +268,7 @@ def get_package_versions() -> Dict[str, Optional[str]]:
|
|||
# CUDA toolkit version bundled with torch
|
||||
try:
|
||||
import torch
|
||||
|
||||
versions["cuda"] = getattr(torch.version, "cuda", None)
|
||||
except Exception:
|
||||
versions["cuda"] = None
|
||||
|
|
@ -265,6 +278,7 @@ def get_package_versions() -> Dict[str, Optional[str]]:
|
|||
|
||||
# ========== Live GPU Utilization (nvidia-smi) ==========
|
||||
|
||||
|
||||
def get_gpu_utilization() -> Dict[str, Any]:
|
||||
"""
|
||||
Return a live snapshot of GPU utilization via ``nvidia-smi``.
|
||||
|
|
@ -312,9 +326,9 @@ def get_gpu_utilization() -> Dict[str, Any]:
|
|||
"memory.used,memory.total,power.draw,power.limit",
|
||||
"--format=csv,noheader,nounits",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5,
|
||||
capture_output = True,
|
||||
text = True,
|
||||
timeout = 5,
|
||||
)
|
||||
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
|
|
@ -360,7 +374,9 @@ def get_gpu_utilization() -> Dict[str, Any]:
|
|||
power_limit = smi_data.get("power_limit")
|
||||
|
||||
vram_used_gb = round(vram_used_mb / 1024, 2) if vram_used_mb is not None else None
|
||||
vram_total_gb = round(vram_total_mb / 1024, 2) if vram_total_mb is not None else None
|
||||
vram_total_gb = (
|
||||
round(vram_total_mb / 1024, 2) if vram_total_mb is not None else None
|
||||
)
|
||||
vram_pct = (
|
||||
round((vram_used_mb / vram_total_mb) * 100, 1)
|
||||
if vram_used_mb is not None and vram_total_mb and vram_total_mb > 0
|
||||
|
|
@ -395,6 +411,7 @@ def get_gpu_utilization() -> Dict[str, Any]:
|
|||
|
||||
_physical_gpu_count: Optional[int] = None
|
||||
|
||||
|
||||
def get_physical_gpu_count() -> int:
|
||||
"""
|
||||
Return the number of physical NVIDIA GPUs on the machine.
|
||||
|
|
@ -409,9 +426,12 @@ def get_physical_gpu_count() -> int:
|
|||
|
||||
try:
|
||||
import subprocess
|
||||
|
||||
result = subprocess.run(
|
||||
["nvidia-smi", "-L"],
|
||||
capture_output=True, text=True, timeout=5,
|
||||
capture_output = True,
|
||||
text = True,
|
||||
timeout = 5,
|
||||
)
|
||||
if result.returncode == 0 and result.stdout.strip():
|
||||
_physical_gpu_count = len(result.stdout.strip().splitlines())
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"""
|
||||
Inference utility functions
|
||||
"""
|
||||
|
||||
from utils.inference.inference_config import load_inference_config
|
||||
|
||||
__all__ = ["load_inference_config"]
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ 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
|
||||
|
|
@ -21,15 +22,15 @@ logger = get_logger(__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:
|
||||
{
|
||||
|
|
@ -41,30 +42,33 @@ def load_inference_config(model_identifier: str) -> Dict[str, Any]:
|
|||
"""
|
||||
# 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:
|
||||
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)),
|
||||
"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)),
|
||||
"trust_remote_code": model_inference.get("trust_remote_code", default_inference.get("trust_remote_code", False)),
|
||||
"trust_remote_code": model_inference.get(
|
||||
"trust_remote_code", default_inference.get("trust_remote_code", False)
|
||||
),
|
||||
}
|
||||
|
||||
return inference_config
|
||||
|
||||
return inference_config
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"""
|
||||
Model and LoRA configuration handling
|
||||
"""
|
||||
|
||||
from .model_config import (
|
||||
ModelConfig,
|
||||
GgufVariantInfo,
|
||||
|
|
@ -24,20 +25,20 @@ from .model_config import (
|
|||
from .checkpoints import scan_checkpoints
|
||||
|
||||
__all__ = [
|
||||
'ModelConfig',
|
||||
'GgufVariantInfo',
|
||||
'is_vision_model',
|
||||
'is_embedding_model',
|
||||
'detect_audio_type',
|
||||
'is_audio_input_type',
|
||||
'VALID_AUDIO_TYPES',
|
||||
'scan_trained_loras',
|
||||
'scan_exported_models',
|
||||
'load_model_defaults',
|
||||
'get_base_model_from_lora',
|
||||
'load_model_config',
|
||||
'list_gguf_variants',
|
||||
'MODEL_NAME_MAPPING',
|
||||
'UI_STATUS_INDICATORS',
|
||||
'scan_checkpoints',
|
||||
"ModelConfig",
|
||||
"GgufVariantInfo",
|
||||
"is_vision_model",
|
||||
"is_embedding_model",
|
||||
"detect_audio_type",
|
||||
"is_audio_input_type",
|
||||
"VALID_AUDIO_TYPES",
|
||||
"scan_trained_loras",
|
||||
"scan_exported_models",
|
||||
"load_model_defaults",
|
||||
"get_base_model_from_lora",
|
||||
"load_model_config",
|
||||
"list_gguf_variants",
|
||||
"MODEL_NAME_MAPPING",
|
||||
"UI_STATUS_INDICATORS",
|
||||
"scan_checkpoints",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"""
|
||||
Checkpoint scanning utilities for discovering training runs and their checkpoints.
|
||||
"""
|
||||
|
||||
import json
|
||||
import structlog
|
||||
from loggers import get_logger
|
||||
|
|
@ -86,7 +87,9 @@ def scan_checkpoints(
|
|||
name_part = parts[0]
|
||||
idx = name_part.find("_")
|
||||
if idx > 0:
|
||||
metadata["base_model"] = name_part[:idx] + "/" + name_part[idx + 1:]
|
||||
metadata["base_model"] = (
|
||||
name_part[:idx] + "/" + name_part[idx + 1 :]
|
||||
)
|
||||
else:
|
||||
metadata["base_model"] = name_part
|
||||
|
||||
|
|
@ -109,13 +112,19 @@ def scan_checkpoints(
|
|||
# Assign the last checkpoint's loss to the main adapter entry
|
||||
if len(checkpoints) > 1:
|
||||
last_checkpoint_loss = checkpoints[-1][2]
|
||||
checkpoints[0] = (checkpoints[0][0], checkpoints[0][1], last_checkpoint_loss)
|
||||
checkpoints[0] = (
|
||||
checkpoints[0][0],
|
||||
checkpoints[0][1],
|
||||
last_checkpoint_loss,
|
||||
)
|
||||
|
||||
models.append((item.name, checkpoints, metadata))
|
||||
logger.debug(f"Found model: {item.name} with {len(checkpoints)} checkpoint(s)")
|
||||
logger.debug(
|
||||
f"Found model: {item.name} with {len(checkpoints)} checkpoint(s)"
|
||||
)
|
||||
|
||||
# Sort by modification time (newest first)
|
||||
models.sort(key=lambda x: Path(x[1][0][1]).stat().st_mtime, reverse=True)
|
||||
models.sort(key = lambda x: Path(x[1][0][1]).stat().st_mtime, reverse = True)
|
||||
|
||||
logger.info(f"Found {len(models)} training runs in {outputs_dir}")
|
||||
return models
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
"""
|
||||
Model and LoRA configuration handling
|
||||
"""
|
||||
|
||||
from transformers import AutoConfig
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Dict, Any
|
||||
|
|
@ -77,7 +78,6 @@ MODEL_NAME_MAPPING = {
|
|||
"unsloth_ERNIE-4.5-VL-28B-A3B-PT.yaml": [
|
||||
"unsloth/ERNIE-4.5-VL-28B-A3B-PT",
|
||||
],
|
||||
|
||||
"tiiuae_Falcon-H1-0.5B-Instruct.yaml": [
|
||||
"tiiuae/Falcon-H1-0.5B-Instruct",
|
||||
"unsloth/Falcon-H1-0.5B-Instruct",
|
||||
|
|
@ -132,7 +132,6 @@ MODEL_NAME_MAPPING = {
|
|||
"unsloth/gpt-oss-20b-unsloth-bnb-4bit",
|
||||
"unsloth/gpt-oss-20b-BF16",
|
||||
],
|
||||
|
||||
"unsloth_gpt-oss-120b.yaml": [
|
||||
"openai/gpt-oss-120b",
|
||||
"unsloth/gpt-oss-120b-unsloth-bnb-4bit",
|
||||
|
|
@ -169,7 +168,6 @@ MODEL_NAME_MAPPING = {
|
|||
"unsloth/Meta-Llama-3.1-405B-bnb-4bit",
|
||||
"meta-llama/Meta-Llama-3.1-405B",
|
||||
],
|
||||
|
||||
"unsloth_Meta-Llama-3.1-8B-Instruct-bnb-4bit.yaml": [
|
||||
"unsloth/Meta-Llama-3.1-8B-Instruct-unsloth-bnb-4bit",
|
||||
"unsloth/Meta-Llama-3.1-8B-Instruct-bnb-4bit",
|
||||
|
|
@ -229,10 +227,9 @@ MODEL_NAME_MAPPING = {
|
|||
"unsloth/Mistral-Nemo-Base-2407-bnb-4bit",
|
||||
"unsloth/Mistral-Nemo-Base-2407",
|
||||
"mistralai/Mistral-Nemo-Base-2407",
|
||||
"unsloth/Mistral-Nemo-Instruct-2407-bnb-4bit",
|
||||
"unsloth/Mistral-Nemo-Instruct-2407",
|
||||
"unsloth/Mistral-Nemo-Instruct-2407-bnb-4bit",
|
||||
"unsloth/Mistral-Nemo-Instruct-2407",
|
||||
"mistralai/Mistral-Nemo-Instruct-2407",
|
||||
|
||||
],
|
||||
"unsloth_Mistral-Small-Instruct-2409.yaml": [
|
||||
"unsloth/Mistral-Small-Instruct-2409-bnb-4bit",
|
||||
|
|
@ -384,7 +381,10 @@ for canonical_file, model_names in MODEL_NAME_MAPPING.items():
|
|||
for model_name in model_names:
|
||||
_REVERSE_MODEL_MAPPING[model_name.lower()] = canonical_file
|
||||
|
||||
def load_model_config(model_name: str, use_auth: bool = False, token: Optional[str] = None):
|
||||
|
||||
def load_model_config(
|
||||
model_name: str, use_auth: bool = False, token: Optional[str] = None
|
||||
):
|
||||
"""
|
||||
Load model config with optional authentication control.
|
||||
"""
|
||||
|
|
@ -392,32 +392,30 @@ def load_model_config(model_name: str, use_auth: bool = False, token: Optional[s
|
|||
if token:
|
||||
# Explicit token provided - use it
|
||||
return AutoConfig.from_pretrained(
|
||||
model_name,
|
||||
trust_remote_code=True,
|
||||
token=token
|
||||
model_name, trust_remote_code = True, token = token
|
||||
)
|
||||
|
||||
if not use_auth:
|
||||
# Load without any authentication (for public model checks)
|
||||
with without_hf_auth():
|
||||
return AutoConfig.from_pretrained(
|
||||
model_name,
|
||||
trust_remote_code=True,
|
||||
token=None
|
||||
model_name, trust_remote_code = True, token = None
|
||||
)
|
||||
|
||||
# Use default authentication (cached tokens)
|
||||
return AutoConfig.from_pretrained(
|
||||
model_name,
|
||||
trust_remote_code=True
|
||||
)
|
||||
return AutoConfig.from_pretrained(model_name, trust_remote_code = True)
|
||||
|
||||
|
||||
# VLM architecture suffixes and known VLM model_type values.
|
||||
_VLM_ARCH_SUFFIXES = ("ForConditionalGeneration", "ForVisionText2Text")
|
||||
_VLM_MODEL_TYPES = {
|
||||
'phi3_v', 'llava', 'llava_next', 'llava_onevision',
|
||||
'internvl_chat', 'cogvlm2', 'minicpmv',
|
||||
"phi3_v",
|
||||
"llava",
|
||||
"llava_next",
|
||||
"llava_onevision",
|
||||
"internvl_chat",
|
||||
"cogvlm2",
|
||||
"minicpmv",
|
||||
}
|
||||
|
||||
# Pre-computed .venv_t5 path and backend dir for subprocess version switching.
|
||||
|
|
@ -426,7 +424,7 @@ _BACKEND_DIR = str(Path(__file__).resolve().parent.parent.parent)
|
|||
|
||||
# Inline script executed in a subprocess with transformers 5.x activated.
|
||||
# Receives model_name and token via argv, prints JSON result to stdout.
|
||||
_VISION_CHECK_SCRIPT = r'''
|
||||
_VISION_CHECK_SCRIPT = r"""
|
||||
import sys, os, json
|
||||
os.environ["TOKENIZERS_PARALLELISM"] = "false"
|
||||
|
||||
|
|
@ -472,10 +470,12 @@ try:
|
|||
except Exception as exc:
|
||||
logger.info(json.dumps({"error": str(exc)}))
|
||||
sys.exit(1)
|
||||
'''
|
||||
"""
|
||||
|
||||
|
||||
def _is_vision_model_subprocess(model_name: str, hf_token: Optional[str] = None) -> bool:
|
||||
def _is_vision_model_subprocess(
|
||||
model_name: str, hf_token: Optional[str] = None
|
||||
) -> bool:
|
||||
"""Run is_vision_model check in a subprocess with transformers 5.x.
|
||||
|
||||
Same pattern as training/inference workers: spawn a clean subprocess
|
||||
|
|
@ -486,16 +486,26 @@ def _is_vision_model_subprocess(model_name: str, hf_token: Optional[str] = None)
|
|||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-c", _VISION_CHECK_SCRIPT,
|
||||
_VENV_T5_DIR, _BACKEND_DIR, model_name, token_arg],
|
||||
capture_output=True, text=True, timeout=60,
|
||||
[
|
||||
sys.executable,
|
||||
"-c",
|
||||
_VISION_CHECK_SCRIPT,
|
||||
_VENV_T5_DIR,
|
||||
_BACKEND_DIR,
|
||||
model_name,
|
||||
token_arg,
|
||||
],
|
||||
capture_output = True,
|
||||
text = True,
|
||||
timeout = 60,
|
||||
)
|
||||
|
||||
if result.returncode != 0:
|
||||
stderr = result.stderr.strip()
|
||||
logger.warning(
|
||||
"Vision check subprocess failed for '%s': %s",
|
||||
model_name, stderr or result.stdout.strip(),
|
||||
model_name,
|
||||
stderr or result.stdout.strip(),
|
||||
)
|
||||
return False
|
||||
|
||||
|
|
@ -503,7 +513,8 @@ def _is_vision_model_subprocess(model_name: str, hf_token: Optional[str] = None)
|
|||
if "error" in data:
|
||||
logger.warning(
|
||||
"Vision check subprocess error for '%s': %s",
|
||||
model_name, data["error"],
|
||||
model_name,
|
||||
data["error"],
|
||||
)
|
||||
return False
|
||||
|
||||
|
|
@ -511,7 +522,10 @@ def _is_vision_model_subprocess(model_name: str, hf_token: Optional[str] = None)
|
|||
logger.info(
|
||||
"Vision check (subprocess, transformers 5.x) for '%s': "
|
||||
"model_type=%s, architectures=%s, is_vision=%s",
|
||||
model_name, data.get("model_type"), data.get("architectures"), is_vlm,
|
||||
model_name,
|
||||
data.get("model_type"),
|
||||
data.get("architectures"),
|
||||
is_vlm,
|
||||
)
|
||||
return is_vlm
|
||||
|
||||
|
|
@ -540,52 +554,54 @@ def is_vision_model(model_name: str, hf_token: Optional[str] = None) -> bool:
|
|||
# because AutoConfig in the main process (transformers 4.57.x) doesn't
|
||||
# recognize their architectures.
|
||||
from utils.transformers_version import needs_transformers_5
|
||||
|
||||
if needs_transformers_5(model_name):
|
||||
logger.info(
|
||||
"Model '%s' needs transformers 5.x — checking vision via subprocess",
|
||||
model_name,
|
||||
)
|
||||
return _is_vision_model_subprocess(model_name, hf_token=hf_token)
|
||||
return _is_vision_model_subprocess(model_name, hf_token = hf_token)
|
||||
|
||||
try:
|
||||
config = load_model_config(model_name, use_auth=True, token=hf_token)
|
||||
config = load_model_config(model_name, use_auth = True, token = hf_token)
|
||||
|
||||
# Exclude audio-only models that share ForConditionalGeneration suffix
|
||||
# (e.g. CsmForConditionalGeneration, WhisperForConditionalGeneration)
|
||||
_audio_only_model_types = {'csm', 'whisper'}
|
||||
model_type = getattr(config, 'model_type', None)
|
||||
_audio_only_model_types = {"csm", "whisper"}
|
||||
model_type = getattr(config, "model_type", None)
|
||||
if model_type in _audio_only_model_types:
|
||||
return False
|
||||
|
||||
# Check 1: Architecture class name patterns
|
||||
if hasattr(config, 'architectures'):
|
||||
is_vlm = any(
|
||||
x.endswith(_VLM_ARCH_SUFFIXES)
|
||||
for x in config.architectures
|
||||
)
|
||||
if hasattr(config, "architectures"):
|
||||
is_vlm = any(x.endswith(_VLM_ARCH_SUFFIXES) for x in config.architectures)
|
||||
if is_vlm:
|
||||
logger.info(f"Model {model_name} detected as VLM: architecture {config.architectures}")
|
||||
logger.info(
|
||||
f"Model {model_name} detected as VLM: architecture {config.architectures}"
|
||||
)
|
||||
return True
|
||||
|
||||
# Check 2: Has vision_config (most VLMs: LLaVA, Gemma-3, Qwen2-VL, etc.)
|
||||
if hasattr(config, 'vision_config'):
|
||||
if hasattr(config, "vision_config"):
|
||||
logger.info(f"Model {model_name} detected as VLM: has vision_config")
|
||||
return True
|
||||
|
||||
# Check 3: Has img_processor (Phi-3.5 Vision uses this instead of vision_config)
|
||||
if hasattr(config, 'img_processor'):
|
||||
if hasattr(config, "img_processor"):
|
||||
logger.info(f"Model {model_name} detected as VLM: has img_processor")
|
||||
return True
|
||||
|
||||
# Check 4: Has image_token_index (common in VLMs for image placeholder tokens)
|
||||
if hasattr(config, 'image_token_index'):
|
||||
if hasattr(config, "image_token_index"):
|
||||
logger.info(f"Model {model_name} detected as VLM: has image_token_index")
|
||||
return True
|
||||
|
||||
# Check 5: Known VLM model_type values that may not match above checks
|
||||
if hasattr(config, 'model_type'):
|
||||
if hasattr(config, "model_type"):
|
||||
if config.model_type in _VLM_MODEL_TYPES:
|
||||
logger.info(f"Model {model_name} detected as VLM: model_type={config.model_type}")
|
||||
logger.info(
|
||||
f"Model {model_name} detected as VLM: model_type={config.model_type}"
|
||||
)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
|
@ -595,19 +611,20 @@ def is_vision_model(model_name: str, hf_token: Optional[str] = None) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
VALID_AUDIO_TYPES = ('snac', 'csm', 'bicodec', 'dac', 'whisper', 'audio_vlm')
|
||||
VALID_AUDIO_TYPES = ("snac", "csm", "bicodec", "dac", "whisper", "audio_vlm")
|
||||
|
||||
# Cache detection results per session to avoid repeated API calls
|
||||
_audio_detection_cache: Dict[str, Optional[str]] = {}
|
||||
|
||||
# Tokenizer token patterns → audio_type (all 6 types detected from tokenizer_config.json)
|
||||
_AUDIO_TOKEN_PATTERNS = {
|
||||
'csm': lambda tokens: '<|AUDIO|>' in tokens and '<|audio_eos|>' in tokens,
|
||||
'whisper': lambda tokens: '<|startoftranscript|>' in tokens,
|
||||
'audio_vlm': lambda tokens: '<audio_soft_token>' in tokens,
|
||||
'bicodec': lambda tokens: any(t.startswith('<|bicodec_') for t in tokens),
|
||||
'dac': lambda tokens: '<|audio_start|>' in tokens and '<|audio_end|>' in tokens,
|
||||
'snac': lambda tokens: sum(1 for t in tokens if t.startswith('<custom_token_')) > 10000,
|
||||
"csm": lambda tokens: "<|AUDIO|>" in tokens and "<|audio_eos|>" in tokens,
|
||||
"whisper": lambda tokens: "<|startoftranscript|>" in tokens,
|
||||
"audio_vlm": lambda tokens: "<audio_soft_token>" in tokens,
|
||||
"bicodec": lambda tokens: any(t.startswith("<|bicodec_") for t in tokens),
|
||||
"dac": lambda tokens: "<|audio_start|>" in tokens and "<|audio_end|>" in tokens,
|
||||
"snac": lambda tokens: sum(1 for t in tokens if t.startswith("<custom_token_"))
|
||||
> 10000,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -631,17 +648,20 @@ def detect_audio_type(model_name: str, hf_token: Optional[str] = None) -> Option
|
|||
return result
|
||||
|
||||
|
||||
def _detect_audio_from_tokenizer(model_name: str, hf_token: Optional[str] = None) -> Optional[str]:
|
||||
def _detect_audio_from_tokenizer(
|
||||
model_name: str, hf_token: Optional[str] = None
|
||||
) -> Optional[str]:
|
||||
"""Detect audio type from tokenizer special tokens (for LLM-based audio models).
|
||||
|
||||
First checks local HF cache, then fetches tokenizer_config.json from HuggingFace.
|
||||
Checks added_tokens_decoder for distinctive patterns.
|
||||
"""
|
||||
|
||||
def _check_token_patterns(tok_config: dict) -> Optional[str]:
|
||||
added = tok_config.get('added_tokens_decoder', {})
|
||||
added = tok_config.get("added_tokens_decoder", {})
|
||||
if not added:
|
||||
return None
|
||||
token_contents = [v.get('content', '') for v in added.values()]
|
||||
token_contents = [v.get("content", "") for v in added.values()]
|
||||
for audio_type, check_fn in _AUDIO_TOKEN_PATTERNS.items():
|
||||
if check_fn(token_contents):
|
||||
return audio_type
|
||||
|
|
@ -650,6 +670,7 @@ def _detect_audio_from_tokenizer(model_name: str, hf_token: Optional[str] = None
|
|||
# 1) Check local HF cache first (works for gated/offline models)
|
||||
try:
|
||||
from huggingface_hub.constants import HF_HUB_CACHE
|
||||
|
||||
cache_dir = Path(HF_HUB_CACHE)
|
||||
repo_dir_name = f"models--{model_name.replace('/', '--')}"
|
||||
repo_dir = cache_dir / repo_dir_name
|
||||
|
|
@ -657,7 +678,10 @@ def _detect_audio_from_tokenizer(model_name: str, hf_token: Optional[str] = None
|
|||
snapshots_dir = repo_dir / "snapshots"
|
||||
if snapshots_dir.exists():
|
||||
for snapshot in snapshots_dir.iterdir():
|
||||
for tok_path in ['tokenizer_config.json', 'LLM/tokenizer_config.json']:
|
||||
for tok_path in [
|
||||
"tokenizer_config.json",
|
||||
"LLM/tokenizer_config.json",
|
||||
]:
|
||||
tok_file = snapshot / tok_path
|
||||
if tok_file.exists():
|
||||
tok_config = json.loads(tok_file.read_text())
|
||||
|
|
@ -672,16 +696,16 @@ def _detect_audio_from_tokenizer(model_name: str, hf_token: Optional[str] = None
|
|||
import requests
|
||||
import os
|
||||
|
||||
paths_to_try = ['tokenizer_config.json', 'LLM/tokenizer_config.json']
|
||||
paths_to_try = ["tokenizer_config.json", "LLM/tokenizer_config.json"]
|
||||
# Use provided token, or fall back to env
|
||||
token = hf_token or os.environ.get('HF_TOKEN')
|
||||
token = hf_token or os.environ.get("HF_TOKEN")
|
||||
headers = {}
|
||||
if token:
|
||||
headers['Authorization'] = f'Bearer {token}'
|
||||
headers["Authorization"] = f"Bearer {token}"
|
||||
|
||||
for tok_path in paths_to_try:
|
||||
url = f"https://huggingface.co/{model_name}/resolve/main/{tok_path}"
|
||||
resp = requests.get(url, headers=headers, timeout=15)
|
||||
resp = requests.get(url, headers = headers, timeout = 15)
|
||||
if not resp.ok:
|
||||
continue
|
||||
|
||||
|
|
@ -692,7 +716,9 @@ def _detect_audio_from_tokenizer(model_name: str, hf_token: Optional[str] = None
|
|||
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not detect audio type from tokenizer for {model_name}: {e}")
|
||||
logger.debug(
|
||||
f"Could not detect audio type from tokenizer for {model_name}: {e}"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
|
|
@ -701,7 +727,7 @@ def is_audio_input_type(audio_type: Optional[str]) -> bool:
|
|||
|
||||
Whisper (ASR) and audio_vlm (Gemma3n) accept audio input.
|
||||
"""
|
||||
return audio_type in ('whisper', 'audio_vlm')
|
||||
return audio_type in ("whisper", "audio_vlm")
|
||||
|
||||
|
||||
def _is_mmproj(filename: str) -> bool:
|
||||
|
|
@ -756,7 +782,8 @@ def detect_gguf_model(path: str) -> Optional[str]:
|
|||
if p.is_dir():
|
||||
gguf_files = sorted(
|
||||
(f for f in p.glob("*.gguf") if not _is_mmproj(f.name)),
|
||||
key=lambda f: f.stat().st_size, reverse=True,
|
||||
key = lambda f: f.stat().st_size,
|
||||
reverse = True,
|
||||
)
|
||||
if gguf_files:
|
||||
return str(gguf_files[0].resolve())
|
||||
|
|
@ -767,9 +794,18 @@ def detect_gguf_model(path: str) -> Optional[str]:
|
|||
# Preferred GGUF quantization levels, in descending priority.
|
||||
# Q4_K_M is a good default: small, fast, acceptable quality.
|
||||
_GGUF_QUANT_PREFERENCE = [
|
||||
"Q4_K_M", "Q4_K_S", "Q5_K_M", "Q5_K_S",
|
||||
"Q6_K", "Q8_0", "Q3_K_M", "Q3_K_L", "Q2_K",
|
||||
"F16", "BF16", "F32",
|
||||
"Q4_K_M",
|
||||
"Q4_K_S",
|
||||
"Q5_K_M",
|
||||
"Q5_K_S",
|
||||
"Q6_K",
|
||||
"Q8_0",
|
||||
"Q3_K_M",
|
||||
"Q3_K_L",
|
||||
"Q2_K",
|
||||
"F16",
|
||||
"BF16",
|
||||
"F32",
|
||||
]
|
||||
|
||||
|
||||
|
|
@ -797,9 +833,10 @@ def _pick_best_gguf(filenames: list[str]) -> Optional[str]:
|
|||
@dataclass
|
||||
class GgufVariantInfo:
|
||||
"""A single GGUF quantization variant from a HuggingFace repo."""
|
||||
filename: str # e.g., "gemma-3-4b-it-Q4_K_M.gguf"
|
||||
quant: str # e.g., "Q4_K_M" (extracted from filename)
|
||||
size_bytes: int # file size
|
||||
|
||||
filename: str # e.g., "gemma-3-4b-it-Q4_K_M.gguf"
|
||||
quant: str # e.g., "Q4_K_M" (extracted from filename)
|
||||
size_bytes: int # file size
|
||||
|
||||
|
||||
def _extract_quant_label(filename: str) -> str:
|
||||
|
|
@ -815,21 +852,23 @@ def _extract_quant_label(filename: str) -> str:
|
|||
"MXFP4_MOE/model-MXFP4_MOE-0001.gguf"→ "MXFP4_MOE"
|
||||
"""
|
||||
import re
|
||||
|
||||
# Use only the basename (rfilename may include directory)
|
||||
basename = filename.rsplit("/", 1)[-1]
|
||||
# Strip .gguf and any shard suffix (-00001-of-00010)
|
||||
stem = re.sub(r'-\d{3,}-of-\d{3,}', '', basename.rsplit(".", 1)[0])
|
||||
stem = re.sub(r"-\d{3,}-of-\d{3,}", "", basename.rsplit(".", 1)[0])
|
||||
# Match known quantization patterns
|
||||
match = re.search(
|
||||
r'(UD-)?' # Optional UD- prefix (Ultra Discrete)
|
||||
r'(MXFP[0-9]+(?:_[A-Z0-9]+)*' # MXFP variants: MXFP4, MXFP4_MOE
|
||||
r'|IQ[0-9]+_[A-Z]+(?:_[A-Z0-9]+)?' # IQ variants: IQ4_XS, IQ4_NL, IQ1_S
|
||||
r'|TQ[0-9]+_[0-9]+' # Ternary quant: TQ1_0, TQ2_0
|
||||
r'|Q[0-9]+_K_[A-Z]+' # K-quant: Q4_K_M, Q3_K_S
|
||||
r'|Q[0-9]+_[0-9]+' # Standard: Q8_0, Q5_1
|
||||
r'|Q[0-9]+_K' # Short K-quant: Q6_K
|
||||
r'|BF16|F16|F32)', # Full precision
|
||||
stem, re.IGNORECASE,
|
||||
r"(UD-)?" # Optional UD- prefix (Ultra Discrete)
|
||||
r"(MXFP[0-9]+(?:_[A-Z0-9]+)*" # MXFP variants: MXFP4, MXFP4_MOE
|
||||
r"|IQ[0-9]+_[A-Z]+(?:_[A-Z0-9]+)?" # IQ variants: IQ4_XS, IQ4_NL, IQ1_S
|
||||
r"|TQ[0-9]+_[0-9]+" # Ternary quant: TQ1_0, TQ2_0
|
||||
r"|Q[0-9]+_K_[A-Z]+" # K-quant: Q4_K_M, Q3_K_S
|
||||
r"|Q[0-9]+_[0-9]+" # Standard: Q8_0, Q5_1
|
||||
r"|Q[0-9]+_K" # Short K-quant: Q6_K
|
||||
r"|BF16|F16|F32)", # Full precision
|
||||
stem,
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if match:
|
||||
prefix = match.group(1) or ""
|
||||
|
|
@ -853,11 +892,11 @@ def list_gguf_variants(
|
|||
"""
|
||||
from huggingface_hub import model_info as hf_model_info
|
||||
|
||||
info = hf_model_info(repo_id, token=hf_token, files_metadata=True)
|
||||
info = hf_model_info(repo_id, token = hf_token, files_metadata = True)
|
||||
variants: list[GgufVariantInfo] = []
|
||||
has_vision = False
|
||||
|
||||
quant_totals: dict[str, int] = {} # quant -> total bytes
|
||||
quant_totals: dict[str, int] = {} # quant -> total bytes
|
||||
quant_first_file: dict[str, str] = {} # quant -> first filename (for display)
|
||||
|
||||
for sibling in info.siblings:
|
||||
|
|
@ -877,11 +916,13 @@ def list_gguf_variants(
|
|||
quant_first_file[quant] = fname
|
||||
|
||||
for quant, total_size in quant_totals.items():
|
||||
variants.append(GgufVariantInfo(
|
||||
filename=quant_first_file[quant],
|
||||
quant=quant,
|
||||
size_bytes=total_size,
|
||||
))
|
||||
variants.append(
|
||||
GgufVariantInfo(
|
||||
filename = quant_first_file[quant],
|
||||
quant = quant,
|
||||
size_bytes = total_size,
|
||||
)
|
||||
)
|
||||
|
||||
return variants, has_vision
|
||||
|
||||
|
|
@ -898,7 +939,7 @@ def detect_gguf_model_remote(
|
|||
try:
|
||||
from huggingface_hub import model_info as hf_model_info
|
||||
|
||||
info = hf_model_info(repo_id, token=hf_token)
|
||||
info = hf_model_info(repo_id, token = hf_token)
|
||||
repo_files = [s.rfilename for s in info.siblings]
|
||||
return _pick_best_gguf(repo_files)
|
||||
except Exception as e:
|
||||
|
|
@ -919,9 +960,9 @@ def download_gguf_file(
|
|||
from huggingface_hub import hf_hub_download
|
||||
|
||||
local_path = hf_hub_download(
|
||||
repo_id=repo_id,
|
||||
filename=filename,
|
||||
token=hf_token,
|
||||
repo_id = repo_id,
|
||||
filename = filename,
|
||||
token = hf_token,
|
||||
)
|
||||
return local_path
|
||||
|
||||
|
|
@ -964,7 +1005,7 @@ def is_embedding_model(model_name: str, hf_token: Optional[str] = None) -> bool:
|
|||
try:
|
||||
from huggingface_hub import model_info as hf_model_info
|
||||
|
||||
info = hf_model_info(model_name, token=hf_token)
|
||||
info = hf_model_info(model_name, token = hf_token)
|
||||
tags = set(info.tags or [])
|
||||
pipeline_tag = info.pipeline_tag or ""
|
||||
|
||||
|
|
@ -1024,16 +1065,21 @@ def scan_trained_loras(outputs_dir: str = str(outputs_root())) -> List[Tuple[str
|
|||
logger.debug(f"Found trained LoRA: {display_name}")
|
||||
|
||||
# Sort by modification time (newest first)
|
||||
trained_loras.sort(key=lambda x: Path(x[1]).stat().st_mtime, reverse=True)
|
||||
trained_loras.sort(key = lambda x: Path(x[1]).stat().st_mtime, reverse = True)
|
||||
|
||||
logger.info(f"Found {len(trained_loras)} trained LoRA adapters in {outputs_dir}")
|
||||
logger.info(
|
||||
f"Found {len(trained_loras)} trained LoRA adapters in {outputs_dir}"
|
||||
)
|
||||
return trained_loras
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error scanning outputs folder: {e}")
|
||||
return []
|
||||
|
||||
def scan_exported_models(exports_dir: str = str(exports_root())) -> List[Tuple[str, str, str, Optional[str]]]:
|
||||
|
||||
def scan_exported_models(
|
||||
exports_dir: str = str(exports_root()),
|
||||
) -> List[Tuple[str, str, str, Optional[str]]]:
|
||||
"""
|
||||
Scan exports folder for exported models (merged, LoRA, GGUF).
|
||||
|
||||
|
|
@ -1082,9 +1128,8 @@ def scan_exported_models(exports_dir: str = str(exports_root())) -> List[Tuple[s
|
|||
|
||||
adapter_config = checkpoint_dir / "adapter_config.json"
|
||||
config_file = checkpoint_dir / "config.json"
|
||||
has_weights = (
|
||||
any(checkpoint_dir.glob("*.safetensors"))
|
||||
or any(checkpoint_dir.glob("*.bin"))
|
||||
has_weights = any(checkpoint_dir.glob("*.safetensors")) or any(
|
||||
checkpoint_dir.glob("*.bin")
|
||||
)
|
||||
has_gguf = any(checkpoint_dir.glob("*.gguf"))
|
||||
|
||||
|
|
@ -1134,7 +1179,9 @@ def scan_exported_models(exports_dir: str = str(exports_root())) -> List[Tuple[s
|
|||
# Fallback: read base model from the original training run's
|
||||
# adapter_config.json in ./outputs/{run_name}/
|
||||
if not base_model:
|
||||
outputs_adapter_cfg = resolve_output_dir(run_dir.name) / "adapter_config.json"
|
||||
outputs_adapter_cfg = (
|
||||
resolve_output_dir(run_dir.name) / "adapter_config.json"
|
||||
)
|
||||
try:
|
||||
if outputs_adapter_cfg.exists():
|
||||
cfg = json.loads(outputs_adapter_cfg.read_text())
|
||||
|
|
@ -1147,7 +1194,7 @@ def scan_exported_models(exports_dir: str = str(exports_root())) -> List[Tuple[s
|
|||
results.append((display_name, model_path, export_type, base_model))
|
||||
logger.debug(f"Found exported model: {display_name} ({export_type})")
|
||||
|
||||
results.sort(key=lambda x: Path(x[1]).stat().st_mtime, reverse=True)
|
||||
results.sort(key = lambda x: Path(x[1]).stat().st_mtime, reverse = True)
|
||||
logger.info(f"Found {len(results)} exported models in {exports_dir}")
|
||||
return results
|
||||
|
||||
|
|
@ -1177,11 +1224,13 @@ def get_base_model_from_lora(lora_path: str) -> Optional[str]:
|
|||
# Try adapter_config.json first
|
||||
adapter_config_path = lora_path_obj / "adapter_config.json"
|
||||
if adapter_config_path.exists():
|
||||
with open(adapter_config_path, 'r') as f:
|
||||
with open(adapter_config_path, "r") as f:
|
||||
config = json.load(f)
|
||||
base_model = config.get("base_model_name_or_path")
|
||||
if base_model:
|
||||
logger.info(f"Detected base model from adapter_config.json: {base_model}")
|
||||
logger.info(
|
||||
f"Detected base model from adapter_config.json: {base_model}"
|
||||
)
|
||||
return base_model
|
||||
|
||||
# Fallback: try training_args.bin (requires torch)
|
||||
|
|
@ -1189,10 +1238,13 @@ def get_base_model_from_lora(lora_path: str) -> Optional[str]:
|
|||
if training_args_path.exists():
|
||||
try:
|
||||
import torch
|
||||
|
||||
training_args = torch.load(training_args_path)
|
||||
if hasattr(training_args, 'model_name_or_path'):
|
||||
if hasattr(training_args, "model_name_or_path"):
|
||||
base_model = training_args.model_name_or_path
|
||||
logger.info(f"Detected base model from training_args.bin: {base_model}")
|
||||
logger.info(
|
||||
f"Detected base model from training_args.bin: {base_model}"
|
||||
)
|
||||
return base_model
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not load training_args.bin: {e}")
|
||||
|
|
@ -1216,22 +1268,23 @@ def get_base_model_from_lora(lora_path: str) -> Optional[str]:
|
|||
except Exception as e:
|
||||
logger.error(f"Error reading base model from LoRA config: {e}")
|
||||
return None
|
||||
pass
|
||||
|
||||
|
||||
# Status indicators that appear in UI dropdowns
|
||||
UI_STATUS_INDICATORS = [" (Ready)", " (Loading...)", " (Active)", "↓ "]
|
||||
|
||||
|
||||
def load_model_defaults(model_name: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Load default training parameters for a model from YAML file.
|
||||
|
||||
|
||||
Args:
|
||||
model_name: Model identifier (e.g., "unsloth/Meta-Llama-3.1-8B-bnb-4bit")
|
||||
|
||||
|
||||
Returns:
|
||||
Dictionary with default parameters from YAML file, or empty dict if not found
|
||||
|
||||
The function looks for a YAML file in configs/model_defaults/ (including subfolders)
|
||||
|
||||
The function looks for a YAML file in configs/model_defaults/ (including subfolders)
|
||||
based on the model name or its aliases from MODEL_NAME_MAPPING.
|
||||
If no specific file exists, it falls back to default.yaml.
|
||||
"""
|
||||
|
|
@ -1239,22 +1292,26 @@ def load_model_defaults(model_name: str) -> Dict[str, Any]:
|
|||
# Get the script directory to locate configs
|
||||
script_dir = Path(__file__).parent.parent.parent
|
||||
defaults_dir = script_dir / "assets" / "configs" / "model_defaults"
|
||||
|
||||
|
||||
# First, check if model is in the mapping
|
||||
if model_name.lower() in _REVERSE_MODEL_MAPPING:
|
||||
canonical_file = _REVERSE_MODEL_MAPPING[model_name.lower()]
|
||||
# Search in subfolders and root
|
||||
for config_path in defaults_dir.rglob(canonical_file):
|
||||
if config_path.is_file():
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
with open(config_path, "r", encoding = "utf-8") as f:
|
||||
config = yaml.safe_load(f) or {}
|
||||
logger.info(f"Loaded model defaults from {config_path} (via mapping)")
|
||||
logger.info(
|
||||
f"Loaded model defaults from {config_path} (via mapping)"
|
||||
)
|
||||
return config
|
||||
|
||||
|
||||
# If model_name is a local path (e.g. /home/.../Spark-TTS-0.5B/LLM from
|
||||
# adapter_config.json), try matching the last 1-2 path components against
|
||||
# the registry (e.g. "Spark-TTS-0.5B/LLM").
|
||||
if model_name not in _REVERSE_MODEL_MAPPING and (model_name.startswith("/") or model_name.startswith(".")):
|
||||
if model_name not in _REVERSE_MODEL_MAPPING and (
|
||||
model_name.startswith("/") or model_name.startswith(".")
|
||||
):
|
||||
parts = Path(model_name).parts
|
||||
for depth in [2, 1]:
|
||||
if len(parts) >= depth:
|
||||
|
|
@ -1263,9 +1320,11 @@ def load_model_defaults(model_name: str) -> Dict[str, Any]:
|
|||
canonical_file = _REVERSE_MODEL_MAPPING[suffix]
|
||||
for config_path in defaults_dir.rglob(canonical_file):
|
||||
if config_path.is_file():
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
with open(config_path, "r", encoding = "utf-8") as f:
|
||||
config = yaml.safe_load(f) or {}
|
||||
logger.info(f"Loaded model defaults from {config_path} (via path suffix '{suffix}')")
|
||||
logger.info(
|
||||
f"Loaded model defaults from {config_path} (via path suffix '{suffix}')"
|
||||
)
|
||||
return config
|
||||
|
||||
# Try exact model name match (for backward compatibility)
|
||||
|
|
@ -1273,48 +1332,58 @@ def load_model_defaults(model_name: str) -> Dict[str, Any]:
|
|||
# Search in subfolders and root
|
||||
for config_path in defaults_dir.rglob(model_filename):
|
||||
if config_path.is_file():
|
||||
with open(config_path, 'r', encoding='utf-8') as f:
|
||||
with open(config_path, "r", encoding = "utf-8") as f:
|
||||
config = yaml.safe_load(f) or {}
|
||||
logger.info(f"Loaded model defaults from {config_path}")
|
||||
return config
|
||||
|
||||
|
||||
# Fall back to default.yaml
|
||||
default_config_path = defaults_dir / "default.yaml"
|
||||
if default_config_path.exists():
|
||||
with open(default_config_path, 'r', encoding='utf-8') as f:
|
||||
with open(default_config_path, "r", encoding = "utf-8") as f:
|
||||
config = yaml.safe_load(f) or {}
|
||||
logger.info(f"Loaded default model defaults from {default_config_path}")
|
||||
return config
|
||||
|
||||
|
||||
logger.warning(f"No default config found for model {model_name}")
|
||||
return {}
|
||||
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading model defaults for {model_name}: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelConfig:
|
||||
"""Configuration for a model to load"""
|
||||
identifier: str # Clean model identifier (org/name or path)
|
||||
display_name: str # Original UI display name
|
||||
path: str # Normalized filesystem path
|
||||
is_local: bool # Is this a local file vs HF model?
|
||||
is_cached: bool # Is this already in HF cache?
|
||||
is_vision: bool # Is this a vision model?
|
||||
is_lora: bool # Is this a lora adapter?
|
||||
is_gguf: bool = False # Is this a GGUF model?
|
||||
is_audio: bool = False # Is this a TTS audio model?
|
||||
audio_type: Optional[str] = None # Audio codec type: 'snac', 'csm', 'bicodec', 'dac'
|
||||
|
||||
identifier: str # Clean model identifier (org/name or path)
|
||||
display_name: str # Original UI display name
|
||||
path: str # Normalized filesystem path
|
||||
is_local: bool # Is this a local file vs HF model?
|
||||
is_cached: bool # Is this already in HF cache?
|
||||
is_vision: bool # Is this a vision model?
|
||||
is_lora: bool # Is this a lora adapter?
|
||||
is_gguf: bool = False # Is this a GGUF model?
|
||||
is_audio: bool = False # Is this a TTS audio model?
|
||||
audio_type: Optional[str] = (
|
||||
None # Audio codec type: 'snac', 'csm', 'bicodec', 'dac'
|
||||
)
|
||||
has_audio_input: bool = False # Accepts audio input (ASR/speech understanding)
|
||||
gguf_file: Optional[str] = None # Full path to the .gguf file (local mode)
|
||||
gguf_mmproj_file: Optional[str] = None # Full path to the mmproj .gguf file (vision projection)
|
||||
gguf_hf_repo: Optional[str] = None # HF repo ID for -hf mode (e.g. "unsloth/gemma-3-4b-it-GGUF")
|
||||
gguf_mmproj_file: Optional[str] = (
|
||||
None # Full path to the mmproj .gguf file (vision projection)
|
||||
)
|
||||
gguf_hf_repo: Optional[str] = (
|
||||
None # HF repo ID for -hf mode (e.g. "unsloth/gemma-3-4b-it-GGUF")
|
||||
)
|
||||
gguf_variant: Optional[str] = None # Quantization variant (e.g. "Q4_K_M")
|
||||
base_model: Optional[str] = None # Base model (for LoRAs)
|
||||
|
||||
@classmethod
|
||||
def from_lora_path(cls, lora_path: str, hf_token: Optional[str] = None) -> Optional['ModelConfig']:
|
||||
def from_lora_path(
|
||||
cls, lora_path: str, hf_token: Optional[str] = None
|
||||
) -> Optional["ModelConfig"]:
|
||||
"""
|
||||
Create ModelConfig from a local LoRA adapter path.
|
||||
|
||||
|
|
@ -1341,26 +1410,26 @@ class ModelConfig:
|
|||
return None
|
||||
|
||||
# Check if base model is vision
|
||||
is_vision = is_vision_model(base_model, hf_token=hf_token)
|
||||
is_vision = is_vision_model(base_model, hf_token = hf_token)
|
||||
|
||||
# Check if base model is audio
|
||||
audio_type = detect_audio_type(base_model, hf_token=hf_token)
|
||||
audio_type = detect_audio_type(base_model, hf_token = hf_token)
|
||||
|
||||
display_name = lora_path_obj.name
|
||||
identifier = lora_path # Use path as identifier for local LoRAs
|
||||
|
||||
return cls(
|
||||
identifier=identifier,
|
||||
display_name=display_name,
|
||||
path=lora_path,
|
||||
is_local=True,
|
||||
is_cached=True, # Local LoRAs are always "cached"
|
||||
is_vision=is_vision,
|
||||
is_lora=True,
|
||||
is_audio=audio_type is not None and audio_type != 'audio_vlm',
|
||||
audio_type=audio_type,
|
||||
has_audio_input=is_audio_input_type(audio_type),
|
||||
base_model=base_model,
|
||||
identifier = identifier,
|
||||
display_name = display_name,
|
||||
path = lora_path,
|
||||
is_local = True,
|
||||
is_cached = True, # Local LoRAs are always "cached"
|
||||
is_vision = is_vision,
|
||||
is_lora = True,
|
||||
is_audio = audio_type is not None and audio_type != "audio_vlm",
|
||||
audio_type = audio_type,
|
||||
has_audio_input = is_audio_input_type(audio_type),
|
||||
base_model = base_model,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
|
|
@ -1374,7 +1443,7 @@ class ModelConfig:
|
|||
hf_token: Optional[str] = None,
|
||||
is_lora: bool = False,
|
||||
gguf_variant: Optional[str] = None,
|
||||
) -> Optional['ModelConfig']:
|
||||
) -> Optional["ModelConfig"]:
|
||||
"""
|
||||
Create ModelConfig from a clean model identifier.
|
||||
|
||||
|
|
@ -1432,7 +1501,7 @@ class ModelConfig:
|
|||
try:
|
||||
meta = json.loads(meta_path.read_text())
|
||||
base = meta.get("base_model")
|
||||
if base and is_vision_model(base, hf_token=hf_token):
|
||||
if base and is_vision_model(base, hf_token = hf_token):
|
||||
base_is_vision = True
|
||||
logger.info(f"GGUF base model '{base}' is a vision model")
|
||||
except Exception as e:
|
||||
|
|
@ -1444,27 +1513,30 @@ class ModelConfig:
|
|||
gguf_is_vision = True
|
||||
logger.info(f"Detected mmproj for vision: {mmproj_file}")
|
||||
elif base_is_vision:
|
||||
logger.warning(f"Base model is vision but no mmproj file found in {gguf_dir}")
|
||||
logger.warning(
|
||||
f"Base model is vision but no mmproj file found in {gguf_dir}"
|
||||
)
|
||||
|
||||
return cls(
|
||||
identifier=identifier,
|
||||
display_name=display_name,
|
||||
path=path,
|
||||
is_local=True,
|
||||
is_cached=True,
|
||||
is_vision=gguf_is_vision,
|
||||
is_lora=False,
|
||||
is_gguf=True,
|
||||
gguf_file=gguf_file,
|
||||
gguf_mmproj_file=mmproj_file,
|
||||
identifier = identifier,
|
||||
display_name = display_name,
|
||||
path = path,
|
||||
is_local = True,
|
||||
is_cached = True,
|
||||
is_vision = gguf_is_vision,
|
||||
is_lora = False,
|
||||
is_gguf = True,
|
||||
gguf_file = gguf_file,
|
||||
gguf_mmproj_file = mmproj_file,
|
||||
)
|
||||
else:
|
||||
# Check if the HF repo contains GGUF files
|
||||
gguf_filename = detect_gguf_model_remote(identifier, hf_token=hf_token)
|
||||
gguf_filename = detect_gguf_model_remote(identifier, hf_token = hf_token)
|
||||
if gguf_filename:
|
||||
# Preflight: verify llama-server binary exists BEFORE user waits
|
||||
# for a multi-GB download that llama-server handles natively
|
||||
from core.inference.llama_cpp import LlamaCppBackend
|
||||
|
||||
if not LlamaCppBackend._find_llama_server_binary():
|
||||
raise RuntimeError(
|
||||
"llama-server binary not found — cannot load GGUF models. "
|
||||
|
|
@ -1472,7 +1544,7 @@ class ModelConfig:
|
|||
)
|
||||
|
||||
# Use list_gguf_variants() to detect vision & resolve variant
|
||||
variants, has_vision = list_gguf_variants(identifier, hf_token=hf_token)
|
||||
variants, has_vision = list_gguf_variants(identifier, hf_token = hf_token)
|
||||
variant = gguf_variant
|
||||
if not variant:
|
||||
# Auto-select best quantization
|
||||
|
|
@ -1489,17 +1561,17 @@ class ModelConfig:
|
|||
f"variant={variant}, vision={has_vision}"
|
||||
)
|
||||
return cls(
|
||||
identifier=identifier,
|
||||
display_name=display_name,
|
||||
path=identifier,
|
||||
is_local=False,
|
||||
is_cached=False,
|
||||
is_vision=has_vision,
|
||||
is_lora=False,
|
||||
is_gguf=True,
|
||||
gguf_file=None,
|
||||
gguf_hf_repo=identifier,
|
||||
gguf_variant=variant,
|
||||
identifier = identifier,
|
||||
display_name = display_name,
|
||||
path = identifier,
|
||||
is_local = False,
|
||||
is_cached = False,
|
||||
is_vision = has_vision,
|
||||
is_lora = False,
|
||||
is_gguf = True,
|
||||
gguf_file = None,
|
||||
gguf_hf_repo = identifier,
|
||||
gguf_variant = variant,
|
||||
)
|
||||
|
||||
# Auto-detect LoRA for local paths (check adapter_config.json on disk)
|
||||
|
|
@ -1507,20 +1579,25 @@ class ModelConfig:
|
|||
detected_base = get_base_model_from_lora(path)
|
||||
if detected_base:
|
||||
is_lora = True
|
||||
logger.info(f"Auto-detected local LoRA adapter at '{path}' (base: {detected_base})")
|
||||
|
||||
logger.info(
|
||||
f"Auto-detected local LoRA adapter at '{path}' (base: {detected_base})"
|
||||
)
|
||||
|
||||
# Auto-detect LoRA for remote HF models (check repo file listing)
|
||||
if not is_lora and not is_local:
|
||||
try:
|
||||
from huggingface_hub import model_info as hf_model_info
|
||||
info = hf_model_info(identifier, token=hf_token)
|
||||
|
||||
info = hf_model_info(identifier, token = hf_token)
|
||||
repo_files = [s.rfilename for s in info.siblings]
|
||||
if "adapter_config.json" in repo_files:
|
||||
is_lora = True
|
||||
logger.info(f"Auto-detected remote LoRA adapter: '{identifier}'")
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not check remote LoRA status for '{identifier}': {e}")
|
||||
|
||||
logger.debug(
|
||||
f"Could not check remote LoRA status for '{identifier}': {e}"
|
||||
)
|
||||
|
||||
# Handle LoRA adapters
|
||||
base_model = None
|
||||
if is_lora:
|
||||
|
|
@ -1531,15 +1608,20 @@ class ModelConfig:
|
|||
# Remote LoRA: download adapter_config.json from HF
|
||||
try:
|
||||
from huggingface_hub import hf_hub_download
|
||||
config_path = hf_hub_download(identifier, "adapter_config.json", token=hf_token)
|
||||
with open(config_path, 'r') as f:
|
||||
|
||||
config_path = hf_hub_download(
|
||||
identifier, "adapter_config.json", token = hf_token
|
||||
)
|
||||
with open(config_path, "r") as f:
|
||||
adapter_config = json.load(f)
|
||||
base_model = adapter_config.get("base_model_name_or_path")
|
||||
if base_model:
|
||||
logger.info(f"Resolved remote LoRA base model: '{base_model}'")
|
||||
except Exception as e:
|
||||
logger.warning(f"Could not download adapter_config.json for '{identifier}': {e}")
|
||||
|
||||
logger.warning(
|
||||
f"Could not download adapter_config.json for '{identifier}': {e}"
|
||||
)
|
||||
|
||||
if not base_model:
|
||||
logger.warning(f"Could not determine base model for LoRA '{path}'")
|
||||
return None
|
||||
|
|
@ -1547,34 +1629,35 @@ class ModelConfig:
|
|||
else:
|
||||
check_model = identifier
|
||||
|
||||
vision = is_vision_model(check_model, hf_token=hf_token)
|
||||
audio_type_val = detect_audio_type(check_model, hf_token=hf_token)
|
||||
vision = is_vision_model(check_model, hf_token = hf_token)
|
||||
audio_type_val = detect_audio_type(check_model, hf_token = hf_token)
|
||||
has_audio_in = is_audio_input_type(audio_type_val)
|
||||
|
||||
display_name = Path(path).name if is_local else identifier.split("/")[-1]
|
||||
|
||||
return cls(
|
||||
identifier=identifier,
|
||||
display_name=display_name,
|
||||
path=path,
|
||||
is_local=is_local,
|
||||
is_cached=is_model_cached(identifier) if not is_local else True,
|
||||
is_vision=vision,
|
||||
is_lora=is_lora,
|
||||
is_audio=audio_type_val is not None and audio_type_val != 'audio_vlm',
|
||||
audio_type=audio_type_val,
|
||||
has_audio_input=has_audio_in,
|
||||
base_model=base_model,
|
||||
identifier = identifier,
|
||||
display_name = display_name,
|
||||
path = path,
|
||||
is_local = is_local,
|
||||
is_cached = is_model_cached(identifier) if not is_local else True,
|
||||
is_vision = vision,
|
||||
is_lora = is_lora,
|
||||
is_audio = audio_type_val is not None and audio_type_val != "audio_vlm",
|
||||
audio_type = audio_type_val,
|
||||
has_audio_input = has_audio_in,
|
||||
base_model = base_model,
|
||||
)
|
||||
|
||||
|
||||
@classmethod
|
||||
def from_ui_selection(cls,
|
||||
dropdown_value: Optional[str],
|
||||
search_value: Optional[str],
|
||||
local_models: list = None,
|
||||
hf_token: Optional[str] = None,
|
||||
is_lora: bool = False) -> Optional['ModelConfig']:
|
||||
def from_ui_selection(
|
||||
cls,
|
||||
dropdown_value: Optional[str],
|
||||
search_value: Optional[str],
|
||||
local_models: list = None,
|
||||
hf_token: Optional[str] = None,
|
||||
is_lora: bool = False,
|
||||
) -> Optional["ModelConfig"]:
|
||||
"""
|
||||
Create a universal ModelConfig from UI dropdown/search selections.
|
||||
Handles base models and LoRA adapters.
|
||||
|
|
@ -1592,7 +1675,9 @@ class ModelConfig:
|
|||
|
||||
# Use the correct 'local_models' parameter to resolve display names
|
||||
if " (Active)" in selected or " (Ready)" in selected:
|
||||
clean_display_name = selected.replace(" (Active)", "").replace(" (Ready)", "")
|
||||
clean_display_name = selected.replace(" (Active)", "").replace(
|
||||
" (Ready)", ""
|
||||
)
|
||||
if local_models:
|
||||
for local_display, local_path in local_models:
|
||||
if local_display == clean_display_name:
|
||||
|
|
@ -1621,25 +1706,28 @@ class ModelConfig:
|
|||
# For a LoRA, we MUST find its base model.
|
||||
base_model = get_base_model_from_lora(path)
|
||||
if not base_model:
|
||||
logger.warning(f"Could not determine base model for LoRA '{path}'. Cannot create config.")
|
||||
return None # Cannot proceed without a base model
|
||||
logger.warning(
|
||||
f"Could not determine base model for LoRA '{path}'. Cannot create config."
|
||||
)
|
||||
return None # Cannot proceed without a base model
|
||||
|
||||
# A LoRA's vision capability is determined by its base model.
|
||||
is_vision = is_vision_model(base_model, hf_token=hf_token)
|
||||
is_vision = is_vision_model(base_model, hf_token = hf_token)
|
||||
else:
|
||||
# For a base model, just check its own vision status.
|
||||
is_vision = is_vision_model(identifier, hf_token=hf_token)
|
||||
is_vision = is_vision_model(identifier, hf_token = hf_token)
|
||||
|
||||
from utils.paths import is_model_cached
|
||||
|
||||
is_cached = is_model_cached(identifier) if not is_local else True
|
||||
|
||||
return cls(
|
||||
identifier=identifier,
|
||||
display_name=display_name,
|
||||
path=path,
|
||||
is_local=is_local,
|
||||
is_cached=is_cached,
|
||||
is_vision=is_vision,
|
||||
is_lora=is_lora,
|
||||
base_model=base_model, # This will be None for base models, and populated for LoRAs
|
||||
identifier = identifier,
|
||||
display_name = display_name,
|
||||
path = path,
|
||||
is_local = is_local,
|
||||
is_cached = is_cached,
|
||||
is_vision = is_vision,
|
||||
is_lora = is_lora,
|
||||
base_model = base_model, # This will be None for base models, and populated for LoRAs
|
||||
)
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue