Studio: train a speculative decoding drafter with fine-tunes and export it for llama.cpp and vLLM (#13806)

Lets Unsloth Studio and Unsloth Desktop train a speculative decoding
drafter together with a fine-tune, and ships it with merged and GGUF
exports so the fine-tuned model decodes faster in llama.cpp and vLLM.
Builds on #13719 (merged) and unslothai/unsloth-zoo#1748 / #1762
(merged).

## What users see

Train tab, advanced mode: a new collapsed section "Speculative Decoding
Drafter" with one checkbox, "Train a speculative decoding drafter". What
it does depends on the selected model:

- Model has a native MTP head (Qwen3.5 / 3.6, GLM-4.5 / 4.7, Qwen3-Next,
...): "Retrains this model's MTP head".
- Model has no MTP head (Llama, Qwen3, Gemma 4, gpt-oss, ...): "Trains
an EAGLE-3 drafter (this model has no MTP head)".
- Neither engine can run a drafter for it (Gemma 3, Mistral 3):
disabled, with the reason.

Expanded: data source (dataset answers, or the model's own answers:
slower, closer to what the model generates), number of examples, epochs,
learning rate, draft tokens. With the box unticked nothing changes: the
training request is byte-identical to today's.

After the fine-tune finishes, a "Training drafter" phase trains the
drafter against the fine-tuned model (frozen, adapter active). A drafter
failure is reported as a warning and the fine-tune is kept; stopping
during this phase discards only the drafter. The export page then says
"Includes trained drafter (MTP | EAGLE-3)" and shows the `vllm serve`
command.

## How it works

Backend:
- `TrainingStartRequest.drafter` (optional) -> route -> worker config ->
`core/training/drafter.py`, run in the worker after the fine-tune is
saved: frees the SFT optimizer and data, builds examples (SFT rows with
the same prompt masking, or greedy regeneration), then
`FastModel.get_mtp_head` + `UnslothMTPTrainer` (native head, saved as
`mtp_head.safetensors` beside the adapter) or the zoo EAGLE-3 trainer
(saved to `eagle3/`). Writes `drafter.json` (strategy, layers, metrics).
Progress has its own step counter and does not touch the fine-tune's
loss history.
- `GET /api/train/drafter-capability?model_name=...`: native head
detected from the checkpoint's tensor index (not the config alone),
otherwise EAGLE-3 support for the architecture.
- Export: the native head is re-attached when export reloads the adapter
and stitched into merged and GGUF outputs (#13719). EAGLE-3 is copied to
`<export>/eagle3` (verifier = the Hub repo on push, else the base model)
and converted to `eagle3-<name>-F16.gguf` next to GGUF exports;
`drafter.json` carries the `vllm serve ... --speculative-config`
command.
- Inference: llama-server gets `--model-draft <eagle3 gguf> --spec-type
draft-eagle3` when an export's drafter sits beside the GGUF and the
binary supports it; a GGUF whose `drafter.json` says its MTP head was
trained turns MTP on in auto even under the 3B size floor and the MLA
default-off (the user trained it to be used). GGUFs without that file
route exactly as before. The managed vLLM engine adds
`--speculative-config` when the model folder has a `drafter.json`.
- Fix: after an export reload, an outer save wrapper hid the MTP save
wrapper, so the head was stitched twice per save. Internal savers now
set a context flag the wrapper honours wherever it sits in the chain.

Frontend: `drafter-section.tsx`, store / types / defaults / persistence
(v23, old state migrates to off) / YAML / payload mapping (sent only
when ticked, supported for the selected model, and not CPT / embedding /
decision / MLX), `training_drafter` phase label, export banner; strings
in all 14 locales.

## Tests

- Backend: `test_drafter_training.py`, `test_drafter_launch_flags.py`,
the managed engine, admission and phase sync suites: 439 passed.
- Frontend: `training-start-payload-drafter.test.ts` (off payload
unchanged in every gated case, on payload = off payload plus exactly the
drafter block, bounds, persistence migration, YAML round trip) and
persistence: 24 passed; `npm run typecheck`, `npm run
i18n:check:strict`, `npm run build` pass.

Regression A/B (Studio journeys auth / settings / crawl / inference
settings / parallel / train_responses_only / export_reload / diffusion
train, plus the core regression smoke, `jobs/sft.py`,
`jobs/inference_smoke.py`), base vs head on Colab A100 / L4 and Kaggle
T4x2: no functional regression (63 steps identical, 10 DOM-only, 1
failing on both sides: `export_reload/export_merged`). The visual diffs
that reproduced are the Train form (the new section) and a chat title in
a parallel-streams journey whose order depends on which stream finishes
first.

## End-to-end results

Driven only through Studio's HTTP API on Colab (train with the drafter,
merged 16-bit + GGUF Q8_0 export, then serve), 60 held-out prompts, 192
new tokens, greedy, arms interleaved on one GPU, 95% paired bootstrap
CIs over prompts.

Qwen3.5-2B, native MTP head retrained on 600 of the model's own answers
(L4, bf16):

| Engine | Arm | Acceptance | Speedup vs no draft |
|---|---|---|---|
| llama.cpp | stock head | 0.740 | 1.51x [1.48, 1.55] |
| llama.cpp | trained head | 0.757 | 1.53x [1.49, 1.57] |
| vLLM 0.31.0 | stock head | 0.747 | 1.55x [1.51, 1.59] |
| vLLM 0.31.0 | trained head | 0.766 | 1.57x [1.53, 1.61] |

Trained vs stock is a tie on this small model (llama.cpp 1.013x [0.996,
1.032], vLLM 1.015x [0.999, 1.031]; an A100 run gave 1.035x [1.018,
1.052]). The default learning rate for the MTP head is 2e-5: at 1e-4 the
retrained head drafted worse than stock (acceptance 0.53 vs 0.70).

Qwen3-1.7B, new EAGLE-3 drafter from 6,000 dataset rows, 2 epochs (T4,
fp16): llama.cpp acceptance 0.449, 1.28x [1.21, 1.35] vs no draft, 60/60
outputs identical; vLLM acceptance 0.463, about 2.2x (the T4 no-draft
baseline was noisy).

## Limits

- A drafter learns the prompt format it was trained on: trained on
Alpaca-formatted rows, it drafts worse on chat-template prompts (the
EAGLE-3 drafter above was slower than no draft there, 0.81x), so train
it on rows formatted the way the model will be served.
- "Model's own answers" regenerates every example (600 prompts took
about 35 minutes on an A100).
- EAGLE-3 was not run on a bf16 GPU end to end.
- The trained-MTP marker lives in `drafter.json` beside the GGUF, not in
GGUF metadata; a GGUF downloaded on its own keeps Studio's default MTP
policy.
- LoRA-only exports keep the MTP head beside the adapter but do not
carry the EAGLE-3 folder.
- No drafter training on Mac (MLX) yet.

## Screenshots

Before (base) on the left, after (this PR) on the right.

Train tab, advanced view: the new collapsed "Speculative Decoding
Drafter" section under the training hyperparameters.

![drafter
section](https://raw.githubusercontent.com/danielhanchen/unsloth-staging-2/pr-media/pr/13806/pr13806_drafter_01-a21b63746682.png)

Qwen3.5-2B (native MTP head): "Retrains this model's MTP head".

![qwen3.5
mtp](https://raw.githubusercontent.com/danielhanchen/unsloth-staging-2/pr-media/pr/13806/pr13806_drafter_02-2b0f9d32bf03.png)

Qwen3-1.7B (no MTP head): "Trains an EAGLE-3 drafter (this model has no
MTP head)".

![qwen3
eagle3](https://raw.githubusercontent.com/danielhanchen/unsloth-staging-2/pr-media/pr/13806/pr13806_drafter_03-9edab2487b0d.png)

Gemma 3 1B: no drafter either engine can run, so the checkbox is
disabled with the reason.

![gemma3
disabled](https://raw.githubusercontent.com/danielhanchen/unsloth-staging-2/pr-media/pr/13806/pr13806_drafter_04-5a47939d8c41.png)
This commit is contained in:
Daniel Han 2026-10-15 10:32:14 -07:00 • committed by GitHub
parent dac778ce2a
commit 1be7795180
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
55 changed files with 4270 additions and 19 deletions

View file

@ -0,0 +1,332 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Carry a drafter trained with the fine-tune (core/training/drafter.py) into merged and GGUF exports.
The MTP head needs no copying: the export reload re-attaches `mtp_head.safetensors` and the merged / GGUF
savers stitch it. An EAGLE-3 drafter is a separate model, copied next to the export. Each export gets a
`drafter.json` saying how to serve the pair; Studio's llama-server launcher reads it beside a GGUF.
"""
from __future__ import annotations
import json
import os
import shlex
import shutil
from typing import Any, Optional
from loggers import get_logger
logger = get_logger(__name__)
DRAFTER_MANIFEST = "drafter.json"
EAGLE3_DIRNAME = "eagle3"
def _read_json(path: str) -> Optional[dict]:
try:
with open(path, encoding = "utf-8") as f:
data = json.load(f)
except (OSError, ValueError):
return None
return data if isinstance(data, dict) else None
def _hub_adapter_snapshot(repo_id: str, token = None) -> Optional[str]:
"""A Hub adapter's cached files (its manifest and MTP head): the loader re-attached that head."""
if os.path.isabs(repo_id) or repo_id.count("/") != 1:
return None
try:
from huggingface_hub import hf_hub_download
path = hf_hub_download(repo_id, DRAFTER_MANIFEST, token = token)
for name in ("adapter_config.json", "mtp_head.safetensors"):
try:
hf_hub_download(repo_id, name, token = token)
except Exception:
pass
return os.path.dirname(path)
except Exception:
return None
def training_manifest(checkpoint_dir: Optional[str], token = None) -> Optional[dict]:
"""The manifest the training run wrote, when its drafter is still complete on disk. `token` is
the credential the checkpoint was loaded with (a private Hub adapter)."""
if checkpoint_dir and not os.path.isdir(checkpoint_dir):
checkpoint_dir = _hub_adapter_snapshot(checkpoint_dir, token)
if not checkpoint_dir or not os.path.isdir(checkpoint_dir):
return None
manifest = _read_json(os.path.join(checkpoint_dir, DRAFTER_MANIFEST))
if not manifest or manifest.get("strategy") not in ("mtp", "eagle3"):
return None
if manifest["strategy"] == "eagle3" and not os.path.isfile(
os.path.join(checkpoint_dir, EAGLE3_DIRNAME, "config.json")
):
return None
if manifest["strategy"] == "mtp" and not (
os.path.isfile(os.path.join(checkpoint_dir, "mtp_head.safetensors"))
or not os.path.isfile(os.path.join(checkpoint_dir, "adapter_config.json"))
):
return None
return manifest
def _engines(manifest: dict) -> dict:
# Manifests from before per-engine support was recorded meant both.
engines = manifest.get("engines")
if not isinstance(engines, dict):
return {"vllm": True, "llama_cpp": True}
return {"vllm": bool(engines.get("vllm")), "llama_cpp": bool(engines.get("llama_cpp"))}
def clear_stale(directory: str, ggufs: Optional[list[str]] = None) -> None:
"""Remove a previous export's drafter from a reused folder, so nothing pairs it with this model."""
info = _read_json(os.path.join(directory, DRAFTER_MANIFEST))
if not info or info.get("strategy") not in ("mtp", "eagle3"):
return
listed = info.get("ggufs") or []
if ggufs is not None and listed and not set(listed) & {os.path.basename(g) for g in ggufs}:
return # describes other GGUFs in this folder
for name in info.get("files") or []:
path = os.path.join(directory, os.path.basename(name))
if os.path.basename(name) == EAGLE3_DIRNAME and os.path.isdir(path):
shutil.rmtree(path, ignore_errors = True)
elif name.lower().endswith(".gguf") and os.path.isfile(path):
os.remove(path)
os.remove(os.path.join(directory, DRAFTER_MANIFEST))
def _vllm_command(target: str, spec: dict, hub: bool) -> str:
"""`vllm serve` for the exported pair. Hub repos: the repo id, after a download when the EAGLE-3
drafter sits in a subfolder (vLLM loads a speculative model from a repo id or a local path only)."""
prefix = ""
if hub and spec["method"] == "eagle3":
local = target.rstrip("/").split("/")[-1] or "model"
prefix = f"hf download {shlex.quote(target)} --local-dir {shlex.quote(local)} && "
target, spec = local, dict(spec, model = f"{local}/{EAGLE3_DIRNAME}")
return f"{prefix}vllm serve {shlex.quote(target)} --speculative-config " + shlex.quote(
json.dumps(spec, separators = (",", ":"))
)
def _steps(manifest: dict) -> int:
try:
return max(1, int(manifest.get("draft_steps") or 3))
except (TypeError, ValueError):
return 3
def _write(directory: str, info: dict) -> None:
path = os.path.join(directory, DRAFTER_MANIFEST)
tmp = path + ".tmp"
with open(tmp, "w", encoding = "utf-8") as f:
json.dump(info, f, indent = 1)
os.replace(tmp, path)
def _set_verifier(eagle_dir: str, verifier: str) -> None:
config_path = os.path.join(eagle_dir, "config.json")
config = _read_json(config_path)
if config is None:
raise RuntimeError(f"{config_path} is not a readable EAGLE-3 config")
spec = config.setdefault("speculators_config", {})
spec.setdefault("verifier", {})["name_or_path"] = verifier
with open(config_path, "w", encoding = "utf-8") as f:
json.dump(config, f, indent = 2)
def package_merged(
checkpoint_dir: Optional[str],
final_dir: str,
*,
verifier: Optional[str] = None,
allow_mtp: bool = True,
token = None,
) -> Optional[dict]:
"""Write `drafter.json` (and copy the EAGLE-3 drafter) into a merged export. None when the run
trained no drafter. `verifier` is what the drafter's config names as its target: the Hub repo on a
push, else the base model id (vLLM refuses a local verifier path inside a speculators config, so
local serving passes the target explicitly). `allow_mtp` is False for exports that cannot carry
a stitched head (compressed-tensors, torchao): only an EAGLE-3 drafter ships with those."""
manifest = training_manifest(checkpoint_dir, token)
if manifest is None or (manifest["strategy"] == "mtp" and not allow_mtp):
clear_stale(final_dir)
return None
strategy, steps = manifest["strategy"], _steps(manifest)
engines = _engines(manifest)
hub = verifier is not None
verifier = verifier or manifest.get("base_model") or ""
target = verifier if hub else os.path.abspath(final_dir)
if strategy == "mtp":
spec = {"method": "mtp", "num_speculative_tokens": steps}
files: list[str] = []
else:
dest = os.path.join(final_dir, EAGLE3_DIRNAME)
source = os.path.join(checkpoint_dir, EAGLE3_DIRNAME)
# An export into the training folder itself must not delete the only copy.
if not (os.path.isdir(dest) and os.path.samefile(source, dest)):
shutil.rmtree(dest, ignore_errors = True)
shutil.copytree(source, dest)
_set_verifier(dest, verifier)
spec = {
"method": "eagle3",
"model": os.path.join(target, EAGLE3_DIRNAME),
"num_speculative_tokens": steps,
}
files = [EAGLE3_DIRNAME]
info = {
"strategy": strategy,
"files": files,
"engines": engines,
"vllm_command": _vllm_command(target, spec, hub) if engines["vllm"] else None,
"llama_cpp_args": None,
"base_model": manifest.get("base_model"),
"verifier": verifier if strategy == "eagle3" else None,
"draft_steps": steps,
"metrics": manifest.get("metrics") or {},
}
_write(final_dir, info)
return info
def eagle3_gguf_name(ggufs: list[str]) -> str:
stem = os.path.basename(ggufs[0]).split(".")[0] if ggufs else ""
# `eagle3-` prefix: Studio's model pickers treat it as a drafter companion, not a model.
return f"eagle3-{stem or 'model'}-F16.gguf"
def package_gguf(
checkpoint_dir: Optional[str],
merged_dir: Optional[str],
gguf_dir: str,
ggufs: list[str],
token = None,
) -> tuple[Optional[dict], list[str]]:
"""(drafter info, extra files written into gguf_dir). The MTP head is already inside each GGUF; an
EAGLE-3 drafter is converted beside them against the merged target (the converter reads its config
and tokenizer). Raises when the drafter cannot be converted: a pair missing its drafter is incomplete."""
manifest = training_manifest(checkpoint_dir, token)
engines = _engines(manifest) if manifest else {}
if manifest is None or not engines["llama_cpp"]:
clear_stale(gguf_dir, ggufs)
return None, []
strategy, steps = manifest["strategy"], _steps(manifest)
extra: list[str] = []
names = [os.path.basename(g) for g in ggufs]
if strategy == "mtp":
args = ["--spec-type", "draft-mtp", "--spec-draft-n-max", str(steps)]
else:
if not merged_dir or not os.path.isfile(os.path.join(merged_dir, "config.json")):
raise RuntimeError(
"the merged target checkpoint needed to convert the EAGLE-3 drafter is missing"
)
from unsloth_zoo.speculative import convert_eagle3_to_gguf
out = os.path.join(gguf_dir, eagle3_gguf_name(ggufs))
convert_eagle3_to_gguf(
os.path.join(checkpoint_dir, EAGLE3_DIRNAME), merged_dir, out, outtype = "f16"
)
if not os.path.isfile(out):
raise RuntimeError("EAGLE-3 drafter conversion wrote no GGUF")
extra.append(out)
args = [
"--model-draft",
os.path.basename(out),
"--spec-type",
"draft-eagle3",
"--spec-draft-n-max",
str(steps),
]
info = {
"strategy": strategy,
"files": [os.path.basename(f) for f in extra],
"ggufs": names,
"engines": engines,
"mtp_trained": strategy == "mtp",
"vllm_command": None,
"llama_cpp_args": args,
"base_model": manifest.get("base_model"),
"draft_steps": steps,
"metrics": manifest.get("metrics") or {},
}
_write(gguf_dir, info)
extra.append(os.path.join(gguf_dir, DRAFTER_MANIFEST))
return info, extra
def package_lora(
checkpoint_dir: Optional[str],
adapter_dir: str,
head_saved: Optional[bool] = None,
token = None,
) -> Optional[dict]:
"""Carry the training manifest with a LoRA export that kept the trained MTP head beside it, so a
later merge / GGUF of that adapter still marks the head as trained (Studio's MTP launch policy).
`head_saved` overrides the sidecar check (a Hub push saves the adapter elsewhere)."""
manifest = training_manifest(checkpoint_dir, token)
if head_saved is None:
head_saved = os.path.isfile(os.path.join(adapter_dir, "mtp_head.safetensors"))
if manifest is None or manifest["strategy"] != "mtp" or not head_saved:
clear_stale(adapter_dir)
return None
keep = (
"version",
"strategy",
"base_model",
"draft_steps",
"metrics",
"engines",
"family",
"llama_cpp_status",
"vllm",
"llama_cpp",
"created_at",
)
info = {k: manifest[k] for k in keep if k in manifest}
info["files"] = ["mtp_head.safetensors"]
_write(adapter_dir, info)
return info
def export_drafter_details(output_path: Optional[str]) -> Optional[dict[str, Any]]:
"""The `drafter` block an export response carries (the frontend contract)."""
if not output_path or not os.path.isdir(output_path):
return None
info = _read_json(os.path.join(output_path, DRAFTER_MANIFEST))
if not info or info.get("strategy") not in ("mtp", "eagle3"):
return None
return {
"strategy": info["strategy"],
"files": list(info.get("files") or []),
"vllm_command": info.get("vllm_command"),
"llama_cpp_args": info.get("llama_cpp_args"),
}
def eagle3_sidecar_path(gguf_path: Optional[str]) -> Optional[str]:
"""The EAGLE-3 drafter GGUF exported next to this GGUF, when its manifest pairs them."""
info = gguf_drafter_sidecar(gguf_path)
if not info or info.get("strategy") != "eagle3":
return None
directory = os.path.dirname(os.path.abspath(gguf_path))
for name in info.get("files") or []:
path = os.path.join(directory, os.path.basename(name))
if name.lower().endswith(".gguf") and os.path.isfile(path):
return path
return None
def gguf_drafter_sidecar(gguf_path: Optional[str]) -> Optional[dict]:
"""The export manifest next to a GGUF, when it names that GGUF (llama-server launch policy)."""
if not gguf_path:
return None
directory = os.path.dirname(os.path.abspath(gguf_path))
info = _read_json(os.path.join(directory, DRAFTER_MANIFEST))
if not info or info.get("strategy") not in ("mtp", "eagle3"):
return None
ggufs = info.get("ggufs") or []
if ggufs and os.path.basename(gguf_path) not in ggufs:
return None
return info

View file

@ -729,6 +729,7 @@ class ExportBackend:
def __init__(self):
self.inference_backend = get_inference_backend()
self.current_checkpoint = None
self._checkpoint_token = None
self.current_model = None
self.current_tokenizer = None
self.is_vision = False
@ -757,6 +758,7 @@ class ExportBackend:
self.current_model = None
self.current_tokenizer = None
self.current_checkpoint = None
self._checkpoint_token = None
self._audio_type = None
self.decision = None
@ -1043,6 +1045,9 @@ class ExportBackend:
self.current_model = model
self.current_tokenizer = tokenizer
self.current_checkpoint = checkpoint_path
self._checkpoint_token = token
if not self.is_peft and not _IS_MLX:
self._attach_full_checkpoint_mtp_head(checkpoint_path)
if self._audio_type:
model_type = f"Audio ({self._audio_type})"
@ -1425,6 +1430,13 @@ class ExportBackend:
else save_directory
)
self._write_export_metadata(final_dir)
if not _IS_MLX:
# Compressed / torchao siblings carry no stitched MTP head; an EAGLE-3 drafter ships.
self._package_drafter_merged(
final_dir,
repo_id if push_to_hub else None,
allow_mtp = not (is_compressed or is_torchao),
)
logger.info(f"Model saved successfully to {final_dir}")
output_path = str(Path(final_dir).resolve())
@ -1468,6 +1480,7 @@ class ExportBackend:
save_method = save_method,
**merged_token_kw,
)
self._package_drafter_merged(upload_dir, repo_id)
# Whatever was built, only weights are worth a repo; without them the
# merging push below runs instead, as it did before this was uploaded.
if _holds_checkpoint_weights(upload_dir):
@ -1576,6 +1589,8 @@ class ExportBackend:
else:
self.current_model.save_pretrained(save_directory)
self.current_tokenizer.save_pretrained(save_directory)
# A full fine-tune trained with a drafter ships it like a merged export.
self._package_drafter_merged(save_directory, repo_id if push_to_hub else None)
self._write_export_metadata(save_directory)
logger.info(f"Model saved successfully to {save_directory}")
@ -1617,6 +1632,7 @@ class ExportBackend:
)
self.current_model.save_pretrained(upload_dir)
self.current_tokenizer.save_pretrained(upload_dir)
self._package_drafter_merged(upload_dir, repo_id)
hf_api = HfApi(token = hf_token)
repo_url = hf_api.create_repo(repo_id, private = private, exist_ok = True)
repo_id = getattr(repo_url, "repo_id", repo_id)
@ -1712,6 +1728,7 @@ class ExportBackend:
output_path: Optional[str] = None
exported_ggufs: List[str] = []
drafter_gguf_files: List[str] = []
exported_modelfile = False
exported_modelfile_bytes: Optional[bytes] = None
exported_is_vlm = False
@ -1801,6 +1818,10 @@ class ExportBackend:
"GGUF conversion produced only AppleDouble metadata companions "
f"and no usable .gguf file for {abs_save_dir}"
)
drafter_gguf_files = self._package_drafter_gguf(
reported.get("save_directory") or _model_tmp, abs_save_dir, exported_ggufs
)
exported_ggufs.extend(f for f in drafter_gguf_files if f.endswith(".gguf"))
exported_is_vlm = bool(
reported.get("is_vlm", getattr(self, "is_vision", False))
)
@ -1922,6 +1943,7 @@ class ExportBackend:
allow_patterns = [
*(glob.escape(os.path.basename(f)) for f in exported_ggufs),
*(["Modelfile"] if exported_modelfile else []),
*(["drafter.json"] if drafter_gguf_files else []),
],
)
if exported_config is not None:
@ -1987,6 +2009,98 @@ class ExportBackend:
)
return False, f"GGUF export failed: {str(e)}", None
def _attach_full_checkpoint_mtp_head(self, checkpoint_path: str) -> None:
"""A full fine-tune keeps its trained MTP head only inside its shards, which transformers
drops on load: attach it so every export writes it back."""
try:
from core.export.drafter_export import training_manifest
if not os.path.isdir(checkpoint_path) or os.path.isfile(
os.path.join(checkpoint_path, "adapter_config.json")
):
return
manifest = training_manifest(checkpoint_path)
if not manifest or manifest.get("strategy") != "mtp":
return
from unsloth import FastModel
FastModel.get_mtp_head(self.current_model, source = checkpoint_path)
logger.info(f"Attached the trained MTP head from {checkpoint_path}")
except Exception as exception:
logger.warning(f"Could not attach the trained MTP head: {exception}")
def _package_drafter_merged(
self,
final_dir: str,
repo_id: Optional[str],
allow_mtp: bool = True,
) -> None:
"""Ship the drafter trained with this fine-tune next to a merged export (best-effort: the
merged model is valid without it)."""
try:
from core.export.drafter_export import package_merged
info = package_merged(
self.current_checkpoint,
final_dir,
verifier = repo_id,
allow_mtp = allow_mtp,
token = self._checkpoint_token,
)
if info:
logger.info(f"Packaged the trained {info['strategy']} drafter into {final_dir}")
except Exception as exception:
logger.warning(f"Could not package the trained drafter: {exception}")
def _package_drafter_lora(
self,
adapter_dir: str,
head_saved: Optional[bool] = None,
) -> bool:
try:
from core.export.drafter_export import package_lora
return (
package_lora(
self.current_checkpoint, adapter_dir, head_saved, token = self._checkpoint_token
)
is not None
)
except Exception as exception:
logger.warning(f"Could not carry the drafter manifest with the adapter: {exception}")
return False
def _push_drafter_lora_manifest(self, hf_api, repo_id: str) -> None:
"""push_to_hub uploads the head beside the adapter but not Studio's manifest."""
try:
from unsloth.save import _attached_mtp_head
from core.export.drafter_export import DRAFTER_MANIFEST
with tempfile.TemporaryDirectory() as tmp:
head = _attached_mtp_head(self.current_model) is not None
if self._package_drafter_lora(tmp, head_saved = head):
hf_api.upload_file(
path_or_fileobj = os.path.join(tmp, DRAFTER_MANIFEST),
path_in_repo = DRAFTER_MANIFEST,
repo_id = repo_id,
repo_type = "model",
)
except Exception as exception:
logger.warning(f"Could not upload the drafter manifest with the adapter: {exception}")
def _package_drafter_gguf(self, merged_dir: str, gguf_dir: str, ggufs: List[str]) -> List[str]:
try:
from core.export.drafter_export import package_gguf
info, files = package_gguf(
self.current_checkpoint, merged_dir, gguf_dir, ggufs, token = self._checkpoint_token
)
if info:
logger.info(f"Packaged the trained {info['strategy']} drafter with the GGUF export")
return files
except Exception as exception:
logger.warning(
f"Could not package the trained drafter with the GGUF export: {exception}"
)
return []
def _write_q4nx_companions(self, q4nx_dir: Path, config: Optional[bytes]) -> None:
"""The tokenizer files FastFlowLM loads next to model.q4nx (see q4nx.CONFIG_FILES)."""
with tempfile.TemporaryDirectory(prefix = "_tmp_tokenizer_", dir = q4nx_dir) as scratch:
@ -2357,6 +2471,7 @@ class ExportBackend:
else:
self.current_model.save_pretrained(save_directory)
self.current_tokenizer.save_pretrained(save_directory)
self._package_drafter_lora(save_directory)
self._write_export_metadata(save_directory)
logger.info(f"Adapter saved successfully to {save_directory}")
output_path = str(Path(save_directory).resolve())
@ -2418,6 +2533,7 @@ class ExportBackend:
_publish_unsloth_model_card(hf_api, repo_id, self.current_model, hf_token)
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)
self._push_drafter_lora_manifest(hf_api, repo_id)
logger.info(f"Adapter pushed successfully to {repo_id}")
return True, "LoRA adapter exported successfully", output_path

View file

@ -262,6 +262,11 @@ class EngineAdapter:
*(["--trust-remote-code"] if trust_remote_code else []),
# Prefix caching is on by default in both; this only reports its hits in usage.
"--enable-prompt-tokens-details" if self.name == "vllm" else "--enable-cache-report",
*(
vllm_speculative_args(model)
if self.name == "vllm" and not options.get("no_speculative_config")
else []
),
*self.extra_args,
"--host",
"127.0.0.1",
@ -303,6 +308,59 @@ ADAPTERS = {
}
def _wsl_guest_to_host(path: str, nt: "bool | None" = None) -> str:
"""`/mnt/c/x` (the path the Windows WSL engine is given) back to `C:\\x` for host-side reads."""
import os
import re
if (os.name == "nt" if nt is None else nt) and re.match(r"^/mnt/[a-zA-Z](/|$)", path):
return path[5].upper() + ":\\" + path[7:].replace("/", "\\")
return path
def vllm_speculative_args(model) -> list[str]:
"""`--speculative-config` for a merged Studio export that carries a drafter trained with the
fine-tune (drafter.json): its own MTP head, or the EAGLE-3 drafter in `<export>/eagle3`."""
import json
import os
if not isinstance(model, str):
return []
host = _wsl_guest_to_host(model)
if not os.path.isdir(host):
return []
try:
with open(os.path.join(host, "drafter.json"), encoding = "utf-8") as f:
info = json.load(f)
except (OSError, ValueError):
return []
if not isinstance(info, dict):
return []
# A drafter vLLM cannot run (llama.cpp-only EAGLE-3 targets, some MTP families) must not stop
# the model from starting.
if isinstance(info.get("engines"), dict) and not info["engines"].get("vllm"):
return []
steps = info.get("draft_steps") or 3
if info.get("strategy") == "mtp":
spec = {"method": "mtp", "num_speculative_tokens": int(steps)}
elif info.get("strategy") == "eagle3" and os.path.isfile(
os.path.join(host, "eagle3", "config.json")
):
spec = {
"method": "eagle3",
# The path vLLM sees: the WSL guest's when the model path was translated for it.
"model": (
model.rstrip("/") + "/eagle3"
if host != model
else os.path.join(os.path.abspath(model), "eagle3")
),
"num_speculative_tokens": int(steps),
}
else:
return []
return ["--speculative-config", json.dumps(spec, separators = (",", ":"))]
def launch_arguments(
engine,
python,

View file

@ -822,6 +822,7 @@ _UNRECOVERABLE_LOCAL_ERRNOS = frozenset(
_SPEC_KIND_CAPABILITY: dict[str, str] = {
"dspark": "supports_dspark",
"dflash": "supports_dflash",
"eagle3": "supports_eagle3",
"mtp": "supports_mtp",
}
@ -3315,7 +3316,7 @@ _DRAFTER_KINDS = ("mtp", "dspark", "dflash", "eagle3")
_DRAFTER_DIR_KINDS = ("mtp", "dspark")
# Human label per resolved spec mode, for launch logging and the UI notice.
_DRAFTER_DISPLAY_LABELS = {"dspark": "DSpark", "dflash": "DFlash"}
_DRAFTER_DISPLAY_LABELS = {"dspark": "DSpark", "dflash": "DFlash", "eagle3": "EAGLE-3"}
def _drafter_path_kind(path: str) -> Optional[str]:
@ -7625,6 +7626,7 @@ _CANONICAL_SPEC_MODES = {
"auto",
"mtp",
"dspark",
"eagle3",
"dflash",
"ngram",
"mtp+ngram",
@ -7636,6 +7638,7 @@ _LEGACY_SPEC_MODE_MAP = {
"draft-mtp": "mtp",
"draft-dspark": "dspark",
"draft-dflash": "dflash",
"draft-eagle3": "eagle3",
"ngram-mod": "ngram",
"none": "off",
"disable": "off",
@ -7643,6 +7646,25 @@ _LEGACY_SPEC_MODE_MAP = {
}
def _trained_drafter_sidecar(model_path):
"""(eagle3 drafter path, MTP head trained, draft steps) from the drafter.json a Studio export
wrote beside the GGUF."""
if not model_path:
return None, False, None
try:
from core.export.drafter_export import eagle3_sidecar_path, gguf_drafter_sidecar
info = gguf_drafter_sidecar(model_path) or {}
try:
steps = max(1, int(info["draft_steps"])) if info.get("draft_steps") else None
except (TypeError, ValueError):
steps = None
return eagle3_sidecar_path(model_path), bool(info.get("mtp_trained")), steps
except Exception as exc:
logger.debug("Could not read a drafter manifest beside %s: %s", model_path, exc)
return None, False, None
def _canonicalize_spec_mode(value):
"""Map any accepted ``speculative_type`` input onto a canonical mode.
@ -10387,6 +10409,7 @@ class LlamaCppBackend:
"supports_mtp": False,
"supports_dspark": False,
"supports_dflash": False,
"supports_eagle3": False,
"mtp_probe_inconclusive": True,
"ngram_mod_flavor": None,
"supports_ngram_mod": False,
@ -10451,6 +10474,7 @@ class LlamaCppBackend:
mtp_token: Optional[str] = None
supports_dspark = False
supports_dflash = False
supports_eagle3 = False
ngram_mod_flavor: Optional[str] = None
spec_draft_n_max_flag: Optional[str] = None
spec_draft_n_max_default: Optional[int] = None
@ -10628,6 +10652,9 @@ class LlamaCppBackend:
spec_help.lower(),
)
)
supports_eagle3 = bool(
re.search(r"(?<![a-z0-9_-])draft-eagle3(?![a-z0-9_-])", spec_help.lower())
)
# Same word-boundary match as DSpark: draft-dflash only exists on
# builds that carry the arch, and emitting a --spec-type the binary
# does not know aborts the launch instead of falling back.
@ -10810,6 +10837,7 @@ class LlamaCppBackend:
"supports_mtp": supports_mtp,
"supports_dspark": bool(supports_dspark and saw_spec_type and probe_ok),
"supports_dflash": bool(supports_dflash and saw_spec_type and probe_ok),
"supports_eagle3": bool(supports_eagle3 and saw_spec_type and probe_ok),
"mtp_probe_inconclusive": mtp_probe_inconclusive,
"ngram_mod_flavor": ngram_mod_flavor,
"supports_ngram_mod": ngram_mod_flavor is not None,
@ -33544,6 +33572,8 @@ class LlamaCppBackend:
_spec_requested_dflash = any(
"draft-dflash" in str(t).lower() for t in spec_flags
) or _extra_args_requests_dflash(extra_args, env = _launch_spec_env)
# A paired EAGLE-3 drafter (Studio export) can abort the server the same way.
_spec_requested_eagle3 = any("draft-eagle3" in str(t).lower() for t in spec_flags)
# Any MTP launch, not only tensor: a respawn would replay a drafter that
# aborts every decode (#13253). Cleared if the MTP-drop fallback wins.
_mtp_active_for_launched_server = bool(_spec_requested_mtp)
@ -33625,7 +33655,12 @@ class LlamaCppBackend:
_spec_cpu_replay_cmd: Optional[List[str]] = None
if (
not healthy
and (_spec_requested_mtp or _spec_requested_dspark or _spec_requested_dflash)
and (
_spec_requested_mtp
or _spec_requested_dspark
or _spec_requested_dflash
or _spec_requested_eagle3
)
and not _load_cancelled()
):
_spec_cpu_replay_cmd = list(_last_spawn_cmd)
@ -34376,6 +34411,8 @@ class LlamaCppBackend:
embedded_mtp_partial_offload: bool = False,
draft_device: Optional[str] = None,
mtp_drafter_unloadable: bool = False,
eagle3_draft_path: Optional[str] = None,
mtp_trained: Optional[bool] = None,
) -> List[str]:
"""Return the llama-server flag list for the requested spec mode.
@ -34426,6 +34463,12 @@ class LlamaCppBackend:
self._spec_draft_n_max = None
self._speculative_type = None
self._spec_fallback_reason = None
# A drafter trained with the fine-tune and exported beside this GGUF (drafter.json).
trained_steps = None
if eagle3_draft_path is None or mtp_trained is None:
sidecar_eagle3, sidecar_trained, trained_steps = _trained_drafter_sidecar(model_path)
eagle3_draft_path = eagle3_draft_path or sidecar_eagle3
mtp_trained = sidecar_trained if mtp_trained is None else mtp_trained
# Canonical UI-facing requested mode (legacy values mapped via
# _canonicalize_spec_mode).
@ -34454,8 +34497,13 @@ class LlamaCppBackend:
_mtp_size_b = _extract_model_size_b(model_identifier)
# The sub-3B regression is an embedded-head cost; a separate drafter
# (Gemma) is a cheap standalone model that wins below 3B, so exempt it.
# A head the user trained with this fine-tune is what they asked to draft with, so the
# size floor (a stock-head cost measurement) and the MLA default-off below do not apply.
_mtp_too_small = (
_mtp_size_b is not None and _mtp_size_b < _MTP_MIN_SIZE_B and not bool(mtp_draft_path)
_mtp_size_b is not None
and _mtp_size_b < _MTP_MIN_SIZE_B
and not bool(mtp_draft_path)
and not mtp_trained
)
# Drafterless Gemma (name-only MTP, no embedded head), or any quant whose only
# drafter was dropped as unopenable: emitting MTP would abort llama-server, so
@ -34477,6 +34525,7 @@ class LlamaCppBackend:
and not bool(mtp_draft_path)
and not _mla_mtp_auto_enabled()
and not _arch_has_fast_mla_mtp(getattr(self, "_architecture", None))
and not mtp_trained
)
if user_owns_spec_type:
@ -34500,6 +34549,49 @@ class LlamaCppBackend:
# and under Auto neither the requested mode nor the resolved
# _speculative_type ("default" after a fallback) still carries the kind.
_auto_mode = (canonical_mode or "auto") == "auto"
if canonical_mode == "eagle3" or (
_auto_mode and eagle3_draft_path and caps.get("supports_eagle3")
):
self._spec_drafter_kind = "eagle3"
if not caps.get("supports_eagle3"):
logger.warning(
"EAGLE-3 requested but llama-server lacks --spec-type draft-eagle3; "
"loading without speculative decoding."
)
flags.append("--spec-default")
self._speculative_type = "default"
self._spec_fallback_reason = "binary_no_mtp"
return flags
if not eagle3_draft_path:
logger.warning(
"EAGLE-3 requested but no trained eagle3-*.gguf drafter sits next to this "
"model; loading without speculative decoding."
)
flags.append("--spec-default")
self._speculative_type = "default"
self._spec_fallback_reason = "drafter_not_found"
return flags
draft_n_max = (
int(spec_draft_n_max) if spec_draft_n_max is not None else (trained_steps or 3)
)
if spec_draft_n_max is not None:
self._spec_draft_n_max = draft_n_max
n_max_flag = caps.get("spec_draft_n_max_flag") or "--spec-draft-n-max"
flags.extend(
[
"--model-draft",
eagle3_draft_path,
"--spec-type",
"draft-eagle3",
str(n_max_flag),
str(draft_n_max),
]
)
if draft_device:
flags.extend(["--spec-draft-device", draft_device])
self._speculative_type = "draft-eagle3"
logger.info("Spec decoding: draft-eagle3 using %s", eagle3_draft_path)
return flags
if canonical_mode == "dspark" or (
_auto_mode and dspark_draft_path and caps.get("supports_dspark")
):
@ -34519,6 +34611,8 @@ class LlamaCppBackend:
n = int(spec_draft_n_max)
self._spec_draft_n_max = n
return n
if mtp_trained and trained_steps and self._spec_drafter_kind == "mtp":
return trained_steps # the depth the exported head was trained for
return 2 if gpus else 3
def _emit_dspark() -> None:

View file

@ -490,6 +490,10 @@ class _MambaSeqLimit(RuntimeError):
self.blocks = blocks
class _EngineExited(RuntimeError):
"""The engine process exited during startup (not a stop, cancel or timeout)."""
def _token_file_token(env) -> str | None:
"""The `hf auth login` token a local engine would read from its inherited HF_HOME; the WSL
guest gets its own HF_HOME, so the token has to cross over. Anonymous loads keep none."""
@ -546,8 +550,49 @@ class ManagedEngine:
model_path = None,
):
"""``model`` is the served name; ``model_path`` is what the engine loads when they differ."""
args = (model, context, gpu_ids, env, cancel_event)
options = dict(options or {})
try:
return self._start_seq_limited(
model, context, gpu_ids, env, cancel_event, options, trust_remote_code, model_path
)
except _EngineExited as exc:
if not self._drafter_retry(model, model_path, options):
raise
if cancel_event is not None and cancel_event.is_set():
self.stop()
raise RuntimeError("Model load cancelled") from exc
# A drafter trained with the fine-tune must not keep the model itself from loading.
from loggers import get_logger
get_logger(__name__).warning(
"vLLM did not start with the trained drafter (%s); retrying without it.", exc
)
self._tail.clear()
self._mamba_blocks = None
self.phase = "starting"
return self._start_seq_limited(
model,
context,
gpu_ids,
env,
cancel_event,
{**options, "no_speculative_config": True},
trust_remote_code,
model_path,
)
def _drafter_retry(self, model, model_path, options) -> bool:
"""A failed vLLM start that passed a drafter's --speculative-config is retried without it."""
if self.engine != "vllm" or (options or {}).get("no_speculative_config"):
return False
from core.inference.engine_adapters import vllm_speculative_args
return bool(vllm_speculative_args(model_path or model))
def _start_seq_limited(
self, model, context, gpu_ids, env, cancel_event, options, trust_remote_code, model_path
):
args = (model, context, gpu_ids, env, cancel_event)
key = (
self.engine,
model_path or model,
@ -736,7 +781,7 @@ class ManagedEngine:
message = "Engine failed to start. " + "\n".join(self._tail)
if self._mamba_blocks:
raise _MambaSeqLimit(message, self._mamba_blocks)
raise RuntimeError(message)
raise _EngineExited(message)
try:
response = client.get(self.base_url + "/health", headers = self.headers)
if response.status_code == 200:
@ -753,6 +798,12 @@ class ManagedEngine:
# Not a stop request: an external stop() meanwhile must still cancel the relaunch.
self._teardown()
raise
except _EngineExited:
if self._drafter_retry(model, model_path, options):
self._teardown()
else:
self.stop()
raise
except Exception:
self.stop()
raise

View file

@ -0,0 +1,881 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""Speculative-decoding drafter trained after the fine-tune.
Models with a native MTP head retrain it (llama.cpp `draft-mtp`, vLLM `method="mtp"`); models without one get
an EAGLE-3 drafter in the speculators layout (llama.cpp `draft-eagle3`, vLLM `method="eagle3"`). A fresh MTP
head on an architecture without an MTP graph would run in neither engine, so it is never offered.
"""
from __future__ import annotations
import gc
import hashlib
import json
import os
import re
import shutil
import threading
import time
from typing import Any, Callable, Optional
from loggers import get_logger
logger = get_logger(__name__)
DRAFTER_MANIFEST = "drafter.json"
EAGLE3_DIRNAME = "eagle3"
MTP_HEAD_FILE = "mtp_head.safetensors"
# The MTP head is pretrained: at 1e-4, 600 answers made a fine-tuned Qwen3.5-2B's head draft worse than
# stock in llama.cpp (acceptance 0.740 -> 0.655), at 2e-5 better (0.755). EAGLE-3 starts from scratch.
DEFAULT_LR = {"mtp": 2e-5, "eagle3": 3e-4}
# Families vLLM serves with its MTP proposer (vllm/config/speculative.py, 0.31.0).
_VLLM_MTP_FAMILIES = {
"qwen3_5",
"qwen3_next",
"glm4_moe",
"glm4_moe_lite",
"deepseek_v3",
"deepseek_v32",
"glm_moe_dsa",
"hy_v3",
"nemotron_h",
"step3p5",
"exaone_moe",
}
# EAGLE-3 targets: vLLM `SupportsEagle3` models, and llama.cpp archs that expose layer inputs
# (`t_layer_inp`; gemma3 / mistral3 do not). Used only when unsloth_zoo has no eagle3_supported().
_EAGLE3_VLLM = {
"llama",
"mistral",
"mistral3",
"qwen2",
"qwen3",
"qwen3_moe",
"qwen3_5_text",
"qwen3_5_moe_text",
"qwen3_next",
"gemma4_text",
"gpt_oss",
"minimax_m2",
"nemotron_h",
}
_EAGLE3_LLAMA_CPP = {
"llama",
"mistral",
"qwen3",
"qwen3_moe",
"qwen3_5_text",
"qwen3_5_moe_text",
"qwen3_next",
"gemma4_text",
"gemma3n_text",
"gpt_oss",
"minimax_m2",
"lfm2",
"nemotron_h",
}
_QUANT_SUFFIX = re.compile(
r"-(unsloth-)?bnb-4bit$|-(unsloth-)?(fp8|awq|gptq)(-int4)?$", re.IGNORECASE
)
def _adapter_base(model_name: str, hf_token = None) -> Optional[str]:
"""The base a LoRA adapter (local folder or Hub repo) was trained on; None for a plain model."""
try:
from utils.models.model_config import get_base_model_from_lora_identifier
return get_base_model_from_lora_identifier(model_name, hf_token = hf_token)
except Exception as exc:
logger.debug("Adapter base lookup failed for %s: %s", model_name, exc)
adapter_config = os.path.join(model_name or "", "adapter_config.json")
if model_name and os.path.isfile(adapter_config):
try:
with open(adapter_config, encoding = "utf-8") as f:
return json.load(f).get("base_model_name_or_path")
except (OSError, ValueError):
pass
return None
def _text_config(model_name: str, hf_token: Optional[str] = None):
from transformers import AutoConfig
source = _adapter_base(model_name, hf_token) or model_name
config = AutoConfig.from_pretrained(source, token = hf_token)
return config.get_text_config() if hasattr(config, "get_text_config") else config, source
def _mtp_source_candidates(source: str) -> list[str]:
"""The checkpoint itself, then the original repo a quantized re-upload came from."""
candidates = [source]
stripped = _QUANT_SUFFIX.sub("", source)
if stripped != source:
candidates.append(stripped)
return candidates
def _find_mtp_source(
source: str,
config,
repo_id: Optional[str] = None,
) -> Optional[str]:
"""`repo_id` is the model id when `source` is a pinned snapshot path, whose name has lost the
quantized suffix the original-repo fallback strips."""
from unsloth_zoo.speculative.mtp import find_mtp_tensor_names
candidates = _mtp_source_candidates(source)
if repo_id and repo_id != source:
candidates += [c for c in _mtp_source_candidates(repo_id) if c not in candidates]
for candidate in candidates:
try:
if find_mtp_tensor_names(candidate, config = config):
return candidate
except Exception as exc: # unreadable index, offline, gated
logger.debug("No MTP tensors readable from %s: %s", candidate, exc)
return None
def _mtp_family(config):
try:
from unsloth_zoo.speculative.mtp import mtp_family_name
except ImportError:
return None, None
name = mtp_family_name(config)
if name is None:
return None, None
status = "drafts"
if name != "qwen3_5":
from unsloth_zoo.speculative.mtp_families import MTP_FAMILIES
family = MTP_FAMILIES.get(name)
status = getattr(family, "llama_cpp", "unverified")
return name, status
def eagle3_support(config) -> tuple[bool, bool, Optional[str]]:
"""(vllm, llama_cpp, reason) for an EAGLE-3 drafter on this target."""
try:
from unsloth_zoo.speculative import eagle3_supported
except ImportError:
eagle3_supported = None
model_type = getattr(config, "model_type", None)
vllm = model_type in _EAGLE3_VLLM
llama_cpp = model_type in _EAGLE3_LLAMA_CPP
if eagle3_supported is not None:
ok, reason = eagle3_supported(config)
if not ok:
return False, False, reason
if not (vllm or llama_cpp):
return False, False, f"`{model_type}` has no EAGLE-3 drafter support in vLLM or llama.cpp."
return vllm, llama_cpp, None
_CAPABILITY_CACHE: dict = {}
_CAPABILITY_TTL_S = 600
_TOKEN_ENV = "UNSLOTH_DRAFTER_PROBE_TOKEN"
_PROBE = """
import json, os, sys
os.environ.setdefault("UNSLOTH_IS_PRESENT", "1")
sys.path.insert(0, os.getcwd())
token = os.environ.pop({env!r}, "")
token = False if token == "0" else (token or None)
from core.training.drafter import capability_in_process
print("DRAFTER_CAPABILITY " + json.dumps(capability_in_process(sys.argv[1], token)))
"""
_PROBE_SLOTS = threading.BoundedSemaphore(2)
_PROBE_LOCKS: dict = {}
_PROBE_LOCKS_GUARD = threading.Lock()
def drafter_capability(model_name: str, hf_token = None) -> dict:
"""The capability, probed in a child process: importing unsloth_zoo in the API server races its
background torch warm-up (import deadlock in torch._dynamo) and patches the server process.
One probe per key at a time (later callers wait for its cached answer), at most two at once."""
# Keyed on the token itself: one caller's private-model answer is never served to another.
token_key = hashlib.sha256(hf_token.encode()).hexdigest() if hf_token else hf_token
key = (model_name, token_key)
with _PROBE_LOCKS_GUARD:
lock = _PROBE_LOCKS.setdefault(key, threading.Lock())
with lock:
hit = _CAPABILITY_CACHE.get(key)
if hit and time.time() - hit[0] < _CAPABILITY_TTL_S:
return dict(hit[1])
with _PROBE_SLOTS:
return _probe_capability(model_name, hf_token, key)
def _probe_capability(model_name: str, hf_token, key) -> dict:
import subprocess
import sys
from hub.utils.hf_tokens import apply_token_to_child_env
env = dict(os.environ)
env[_TOKEN_ENV] = "0" if hf_token is False else (hf_token or "")
# Every Hub read in the child (config, tensor index) then runs as exactly this caller.
apply_token_to_child_env(env, hf_token)
result = None
try:
proc = subprocess.run(
[sys.executable, "-c", _PROBE.format(env = _TOKEN_ENV), model_name],
cwd = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))),
env = env,
capture_output = True,
text = True,
timeout = 300,
)
for line in proc.stdout.splitlines():
if line.startswith("DRAFTER_CAPABILITY "):
result = json.loads(line[len("DRAFTER_CAPABILITY ") :])
if result is None:
logger.warning("Drafter capability probe failed: %s", (proc.stderr or "")[-2000:])
except (OSError, subprocess.SubprocessError, ValueError) as exc:
logger.warning("Drafter capability probe failed: %r", exc)
if result is None:
return {
"model_name": model_name,
"has_native_mtp": False,
"strategy": None,
"supported": False,
"reason": "Could not check this model for drafter support.",
"vllm": False,
"llama_cpp": False,
# Not an answer about the model: the Train tab asks again.
"inconclusive": True,
}
_CAPABILITY_CACHE[key] = (time.time(), result)
return dict(result)
def capability_in_process(
model_name: str,
hf_token: Optional[str] = None,
repo_id: Optional[str] = None,
) -> dict:
"""What the Train tab can offer for this model. Head presence comes from the checkpoint's tensor index."""
result = {
"model_name": model_name,
"has_native_mtp": False,
"strategy": None,
"supported": False,
"reason": None,
"vllm": False,
"llama_cpp": False,
}
try:
import unsloth_zoo.speculative.eagle3 # noqa: F401
import unsloth_zoo.speculative.mtp # noqa: F401
except ImportError as exc:
# An older unsloth_zoo would also misread a native-MTP model as EAGLE-3-only.
logger.warning("Drafter trainers unavailable: %r", exc)
result["reason"] = (
f"Drafter training needs a newer unsloth_zoo (pip install -U unsloth_zoo): {exc}"
)[:400]
return result
try:
config, source = _text_config(model_name, hf_token)
except Exception as exc:
result["reason"] = f"Could not read the model config: {exc}"
return result
family, status = _mtp_family(config)
mtp_source = _find_mtp_source(source, config, repo_id) if family is not None else None
if mtp_source is not None:
result.update(
has_native_mtp = True,
strategy = "mtp",
supported = True,
vllm = family in _VLLM_MTP_FAMILIES,
llama_cpp = status in ("drafts", "partial"),
)
if not result["llama_cpp"]:
result["reason"] = "llama.cpp cannot draft with this model's MTP head yet; vLLM can."
return result
vllm, llama_cpp, reason = eagle3_support(config)
if vllm or llama_cpp:
result.update(strategy = "eagle3", supported = True, vllm = vllm, llama_cpp = llama_cpp)
if not llama_cpp:
result["reason"] = "llama.cpp cannot run an EAGLE-3 drafter for this model; vLLM can."
elif not vllm:
result["reason"] = "vLLM cannot run an EAGLE-3 drafter for this model; llama.cpp can."
return result
result["reason"] = reason
return result
def resolve_strategy(requested: str, capability: dict) -> tuple[Optional[str], Optional[str]]:
"""(strategy, refusal reason)."""
if not capability.get("supported"):
return None, capability.get("reason") or "No drafter is available for this model."
if requested in (None, "auto"):
return capability["strategy"], None
if requested == "mtp" and not capability.get("has_native_mtp"):
return None, "This model has no native MTP head; use EAGLE-3."
if requested == "eagle3" and capability.get("strategy") != "eagle3":
# A native-MTP model can still take an EAGLE-3 drafter when the target supports it.
return None, "This model trains its native MTP head; EAGLE-3 is for models without one."
return requested, None
# ---------------------------------------------------------------- examples
def _row_examples(row: dict, max_len: int) -> list[tuple[list[int], list[int]]]:
ids = list(row["input_ids"])
if "labels" in row and row["labels"] is not None:
labels = list(row["labels"])
elif row.get("completion_mask") is not None:
labels = [t if m else -100 for t, m in zip(ids, row["completion_mask"])]
elif row.get("assistant_masks") is not None:
labels = [t if m else -100 for t, m in zip(ids, row["assistant_masks"])]
else:
labels = list(ids)
# Packed rows restart position_ids at every example; split there so no draft crosses a boundary.
positions = row.get("position_ids")
cuts = [0]
if positions is not None:
cuts = [i for i, p in enumerate(positions) if p == 0] or [0]
cuts.append(len(ids))
out = []
for start, end in zip(cuts, cuts[1:]):
seg_ids, seg_labels = ids[start:end][:max_len], labels[start:end][:max_len]
if len(seg_ids) >= 4 and any(t != -100 for t in seg_labels):
out.append((seg_ids, seg_labels))
return out
def collect_examples(train_dataset, max_examples: int, max_len: int) -> list[dict]:
examples = []
for row in train_dataset:
for ids, labels in _row_examples(row, max_len):
examples.append({"input_ids": ids, "labels": labels})
if len(examples) >= max_examples:
return examples
return examples
def _assistant_header(tokenizer) -> list[int]:
"""Token ids the chat template puts between a user turn and the answer ("<|im_start|>assistant\n", ...)."""
text_tokenizer = getattr(tokenizer, "tokenizer", tokenizer)
turn = [{"role": "user", "content": "x"}]
try:
with_prompt = tokenizer.apply_chat_template(
turn, add_generation_prompt = True, tokenize = False
)
without = tokenizer.apply_chat_template(turn, add_generation_prompt = False, tokenize = False)
except Exception:
return []
if not with_prompt.startswith(without):
return []
return text_tokenizer(with_prompt[len(without) :], add_special_tokens = False)["input_ids"]
def _response_markers(tokenizer) -> list[list[int]]:
"""Token ids that can open an answer: the chat template's assistant header, then every response
marker Studio's completion masking knows (rows rendered with a dataset template such as Alpaca's
"### Response:" carry no chat header)."""
text_tokenizer = getattr(tokenizer, "tokenizer", tokenizer)
markers = []
header = _assistant_header(tokenizer)
if header:
markers.append(header)
try:
from utils.datasets.model_mappings import TEMPLATE_TO_RESPONSES_MAPPER
texts = {
m.get("response") for m in TEMPLATE_TO_RESPONSES_MAPPER.values() if isinstance(m, dict)
}
except Exception:
texts = set()
for text in sorted(t for t in texts if isinstance(t, str) and t.strip()):
try:
ids = text_tokenizer(text, add_special_tokens = False)["input_ids"]
except Exception:
continue
if ids and ids not in markers:
markers.append(ids)
return markers
def _marker_end(ids: list[int], marker: list[int]) -> Optional[int]:
"""End of the marker's last occurrence; shorter prefixes cover a marker whose tail tokenizes into
the answer, or a template that adds a thinking block after the header."""
for width in range(len(marker), 0, -1):
part = marker[:width]
for start in range(len(ids) - width, 0, -1):
if ids[start : start + width] == part:
return start + width
if width <= 2:
break
return None
def _prompt_end(ids: list[int], labels: list[int], markers) -> Optional[int]:
"""Where the answer starts: the first trained label, or (rows trained on the whole sequence) after
the last response marker found."""
first = next((i for i, t in enumerate(labels) if t != -100), None)
if first:
return first
if markers and isinstance(markers[0], int):
markers = [markers]
ends = [e for e in (_marker_end(ids, m) for m in markers or []) if e and e < len(ids)]
return max(ends) if ends else None
def regenerate_examples(
model,
tokenizer,
examples: list[dict],
max_new_tokens: int = 512,
batch_size: int = 8,
should_stop: Callable[[], bool] = lambda: False,
max_seq_length: Optional[int] = None,
) -> list[dict]:
"""The fine-tuned model's own greedy answers to each example's prompt (the drafter imitates the model);
prompt + answer stays within `max_seq_length`."""
import torch
text_tokenizer = getattr(tokenizer, "tokenizer", tokenizer)
pad_id = text_tokenizer.pad_token_id
if pad_id is None:
pad_id = text_tokenizer.eos_token_id
eos_id = text_tokenizer.eos_token_id
markers = _response_markers(tokenizer)
prompts = []
for ex in examples:
end = _prompt_end(ex["input_ids"], ex["labels"], markers)
if end and (
max_seq_length is None or end < max_seq_length
): # needs a prompt and room to answer
prompts.append(ex["input_ids"][:end])
out = []
device = next(model.parameters()).device
for b in range(0, len(prompts), batch_size):
if should_stop():
break
batch = prompts[b : b + batch_size]
width = max(map(len, batch))
new_tokens = (
max_new_tokens
if max_seq_length is None
else min(max_new_tokens, max_seq_length - width)
)
ids = torch.tensor([[pad_id] * (width - len(p)) + p for p in batch], device = device)
mask = torch.tensor([[0] * (width - len(p)) + [1] * len(p) for p in batch], device = device)
with torch.no_grad():
gen = model.generate(
input_ids = ids,
attention_mask = mask,
max_new_tokens = new_tokens,
do_sample = False,
pad_token_id = pad_id,
)
for prompt, row in zip(batch, gen[:, width:].tolist()):
if eos_id in row:
row = row[: row.index(eos_id) + 1]
row = [t for t in row if t != pad_id or t == eos_id]
if row:
out.append({"input_ids": prompt + row, "labels": [-100] * len(prompt) + row})
return out
# ---------------------------------------------------------------- phase
def _progress_callback(event_queue, strategy: str, should_stop: Callable[[], bool]):
from transformers import TrainerCallback
class _DrafterProgress(TrainerCallback):
def _emit(
self,
state,
logs = None,
):
logs = logs or {}
loss = logs.get("loss", logs.get("mtp_loss_0"))
top1 = logs.get(
"mtp_top1_0", logs.get("eagle3_acc_0", logs.get("top1", logs.get("acc_0")))
)
event_queue.put(
{
"type": "drafter_progress",
"strategy": strategy,
"step": state.global_step,
"total_steps": state.max_steps,
"loss": loss,
"top1": top1,
"state": "training",
"ts": time.time(),
}
)
event_queue.put(
{
"type": "status",
"ts": time.time(),
"message": f"Training drafter ({_LABEL[strategy]}): step {state.global_step}/{state.max_steps}",
}
)
def on_train_begin(self, args, state, control, **kwargs):
self._emit(state)
def on_log(
self,
args,
state,
control,
logs = None,
**kwargs,
):
self._emit(state, logs)
def on_step_end(self, args, state, control, **kwargs):
if should_stop():
control.should_training_stop = True
return _DrafterProgress()
_LABEL = {"mtp": "MTP head", "eagle3": "EAGLE-3"}
def _training_args(cls, output_dir: str, cfg: dict, strategy: str, seed: int, extra: dict):
import torch
bf16 = bool(torch.cuda.is_available() and torch.cuda.is_bf16_supported())
return cls(
output_dir = output_dir,
per_device_train_batch_size = 4,
gradient_accumulation_steps = 1,
num_train_epochs = float(cfg.get("epochs") or 1.0),
learning_rate = float(cfg.get("learning_rate") or DEFAULT_LR[strategy]),
lr_scheduler_type = "cosine",
warmup_steps = 10,
logging_steps = 5,
save_strategy = "no",
bf16 = bf16,
fp16 = not bf16 and torch.cuda.is_available(),
report_to = "none",
seed = seed,
weight_decay = 0.0,
**extra,
)
def _free_sft_trainer(trainer) -> Any:
"""Drop the SFT optimizer / scheduler / dataloaders; return the train dataset."""
sft = getattr(trainer, "trainer", None)
dataset = getattr(sft, "train_dataset", None)
if sft is not None:
for attr in ("optimizer", "lr_scheduler", "train_dataset", "eval_dataset"):
try:
setattr(sft, attr, None)
except Exception:
pass
trainer.trainer = None
gc.collect()
try:
import torch
if torch.cuda.is_available():
torch.cuda.empty_cache()
except Exception:
pass
return dataset
def write_manifest(output_dir: str, manifest: dict) -> str:
path = os.path.join(output_dir, DRAFTER_MANIFEST)
tmp = path + ".tmp"
with open(tmp, "w", encoding = "utf-8") as f:
json.dump(manifest, f, indent = 1)
os.replace(tmp, path)
return path
def read_manifest(directory: Optional[str]) -> Optional[dict]:
if not directory:
return None
path = os.path.join(directory, DRAFTER_MANIFEST)
try:
with open(path, encoding = "utf-8") as f:
data = json.load(f)
except (OSError, ValueError):
return None
return data if isinstance(data, dict) and data.get("strategy") in ("mtp", "eagle3") else None
def run_drafter_phase(
trainer, config: dict, output_dir: str, event_queue, send_status: Callable[[Any, str], None]
) -> Optional[dict]:
"""Train the drafter after a successful fine-tune. Never raises: a failure is a warning and the
fine-tune stays saved. Returns the manifest written, or None."""
cfg = config.get("drafter") or {}
if not cfg.get("enabled"):
return None
should_stop = lambda: bool(getattr(trainer, "should_stop", False))
strategy = None
eagle_dir = os.path.join(output_dir, EAGLE3_DIRNAME)
try:
if getattr(trainer, "is_vlm", False) or getattr(trainer, "is_audio_vlm", False):
raise RuntimeError(
"drafter training is not available for vision or audio fine-tunes yet"
)
model, tokenizer = trainer.model, trainer.tokenizer
model_name = config.get("model_name") or getattr(trainer, "model_name", "")
text_config = _model_text_config(model)
family, status = _mtp_family(text_config)
# The checkpoint this run actually loaded (a pinned snapshot), under the caller's token.
probe_target = config.get("model_snapshot_path") or model_name
capability = (
capability_in_process(probe_target, config.get("hf_token"), repo_id = model_name)
if probe_target
else {}
)
strategy, refusal = resolve_strategy(cfg.get("strategy", "auto"), capability)
if strategy is None:
raise RuntimeError(refusal)
send_status(event_queue, f"Training drafter ({_LABEL[strategy]}): preparing examples")
max_len = int(config.get("max_seq_length") or 2048)
dataset = _free_sft_trainer(trainer)
if dataset is None:
raise RuntimeError("the fine-tune's training examples are not available")
examples = collect_examples(dataset, int(cfg.get("max_examples") or 2000), max_len)
del dataset
model.eval()
for p in model.parameters():
p.requires_grad_(False)
data_source = "dataset"
if cfg.get("data_source") == "regenerate":
markers = _response_markers(tokenizer)
found = sum(
bool(_prompt_end(ex["input_ids"], ex["labels"], markers)) for ex in examples
)
if not found and examples:
logger.warning(
"Drafter regenerate found no prompts: markers=%s first row ids=%s labels=%s",
markers,
examples[0]["input_ids"][:48],
examples[0]["labels"][:48],
)
send_status(
event_queue,
f"Training drafter ({_LABEL[strategy]}): generating the model's answers to {found} prompts",
)
regenerated = regenerate_examples(
model, tokenizer, examples, should_stop = should_stop, max_seq_length = max_len
)
if regenerated:
examples = regenerated
data_source = "regenerate"
else:
send_status(
event_queue,
f"Training drafter ({_LABEL[strategy]}): no prompts found to answer, using the dataset's answers",
)
if not examples:
raise RuntimeError("no usable training examples")
if should_stop():
# A reused folder may still hold a previous run's drafter.
_discard(output_dir, strategy, model = model)
return None
from datasets import Dataset
train_dataset = Dataset.from_list(examples)
seed = int(config.get("random_seed") or 3407)
steps = int(cfg.get("draft_steps") or 3)
ckpt_dir = os.path.join(output_dir, "drafter_tmp")
callback = _progress_callback(event_queue, strategy, should_stop)
if strategy == "mtp":
from unsloth import FastModel, MTPTrainingArguments, UnslothMTPTrainer
# The pinned snapshot this run loaded, not the repo's default branch.
source = _find_mtp_source(
capability_source(probe_target, config),
text_config,
capability_source(model_name, config) if model_name else None,
)
head = FastModel.get_mtp_head(model, source = source, random_init = source is None)
args = _training_args(
MTPTrainingArguments, ckpt_dir, cfg, strategy, seed, {"mtp_num_steps": steps}
)
drafter_trainer = UnslothMTPTrainer(
model = head,
target_model = model,
args = args,
train_dataset = train_dataset,
processing_class = tokenizer,
)
else:
from unsloth_zoo.speculative import (
Eagle3TrainingArguments,
UnslothEagle3Trainer,
build_eagle3_drafter,
build_eagle3_vocab_map,
)
vocab = int(getattr(text_config, "vocab_size"))
d2t, t2d = build_eagle3_vocab_map(
[e["input_ids"] for e in examples], vocab, draft_vocab_size = min(32000, vocab)
)
head = build_eagle3_drafter(model, d2t, t2d)
args = _training_args(Eagle3TrainingArguments, ckpt_dir, cfg, strategy, seed, {})
drafter_trainer = UnslothEagle3Trainer(
model = head,
target_model = model,
args = args,
train_dataset = train_dataset,
processing_class = tokenizer,
)
drafter_trainer.add_callback(callback)
result = drafter_trainer.train()
shutil.rmtree(ckpt_dir, ignore_errors = True)
if should_stop():
# Stop during this phase keeps the fine-tune and drops only the drafter.
_discard(output_dir, strategy, head = head, model = model)
return None
send_status(event_queue, f"Training drafter ({_LABEL[strategy]}): saving")
metrics = {
k: v
for k, v in (getattr(result, "metrics", None) or {}).items()
if isinstance(v, (int, float))
}
history = [h for h in drafter_trainer.state.log_history if isinstance(h, dict)]
for entry in reversed(history):
for key, value in entry.items():
if (
key.startswith(
("mtp_top1_", "mtp_loss_", "eagle3_acc_", "eagle3_loss_", "acc_", "top1")
)
and key not in metrics
):
metrics[key] = value
base_model = capability_source(model_name, config)
manifest = {
"version": 1,
"strategy": strategy,
"base_model": base_model,
"adapter_dir": output_dir,
"draft_steps": steps,
"examples": len(examples),
"data_source": data_source,
"metrics": metrics,
"created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
# Which engines can run this drafter: exports and launchers only enable it there.
"engines": {
"vllm": bool(capability.get("vllm")),
"llama_cpp": bool(capability.get("llama_cpp")),
},
}
if should_stop():
_discard(output_dir, strategy, head = head, model = model)
return None
if strategy == "mtp":
from unsloth.save import _save_mtp_head_beside_adapter, _stitch_mtp_head
if os.path.isfile(os.path.join(output_dir, "adapter_config.json")):
_save_mtp_head_beside_adapter(model, output_dir)
manifest["files"] = [MTP_HEAD_FILE]
else:
_stitch_mtp_head(model, output_dir)
manifest["files"] = []
manifest.update(
family = family,
llama_cpp_status = status,
vllm = {"method": "mtp", "num_speculative_tokens": steps},
llama_cpp = {"spec_type": "draft-mtp", "spec_draft_n_max": steps},
)
else:
from unsloth_zoo.speculative import save_eagle3_drafter
shutil.rmtree(eagle_dir, ignore_errors = True)
save_eagle3_drafter(
eagle_dir, head, verifier_name_or_path = base_model, speculative_tokens = steps
)
manifest["files"] = [EAGLE3_DIRNAME]
manifest["aux_layer_ids"] = list(getattr(head, "aux_layer_ids", None) or [])
manifest.update(
vllm = {"method": "eagle3", "num_speculative_tokens": steps},
llama_cpp = {"spec_type": "draft-eagle3", "spec_draft_n_max": steps},
)
if should_stop() and manifest["files"]:
# Stop pressed while saving: still drop the drafter, before any manifest names it. A head
# already stitched into a full fine-tune's shards cannot be dropped, so it stays recorded.
_discard(output_dir, strategy, head = head, model = model)
return None
write_manifest(output_dir, manifest)
event_queue.put(
{
"type": "drafter_progress",
"strategy": strategy,
"step": None,
"total_steps": None,
"loss": metrics.get("train_loss"),
"top1": metrics.get("mtp_top1_0", metrics.get("eagle3_acc_0")),
"state": "done",
"ts": time.time(),
}
)
return manifest
except Exception as exc:
logger.warning("Drafter training failed: %s", exc, exc_info = True)
_discard(output_dir, strategy)
event_queue.put(
{
"type": "warning",
"ts": time.time(),
"message": f"Drafter training failed, the fine-tuned model is saved without it: {exc}",
}
)
event_queue.put(
{
"type": "drafter_progress",
"strategy": strategy,
"step": None,
"total_steps": None,
"loss": None,
"top1": None,
"state": "failed",
"ts": time.time(),
}
)
return None
finally:
gc.collect()
def capability_source(model_name: str, config: dict) -> str:
"""The base checkpoint id (a local or Hub adapter resolves to its base)."""
if not model_name:
return model_name
return _adapter_base(model_name, config.get("hf_token")) or model_name
def _model_text_config(model):
config = getattr(model, "config", None)
if config is None:
return None
return config.get_text_config() if hasattr(config, "get_text_config") else config
def discard_drafter_artifacts(output_dir: str) -> None:
"""A run that will not train a drafter must not leave a previous run's in a reused folder."""
if output_dir and os.path.isfile(os.path.join(output_dir, DRAFTER_MANIFEST)):
_discard(output_dir, None)
def _discard(
output_dir: str,
strategy: Optional[str],
head = None,
model = None,
) -> None:
"""Remove anything this phase may have written; the fine-tune itself is untouched."""
if model is not None:
try:
object.__delattr__(model, "_unsloth_mtp_head")
except AttributeError:
pass
for name in (EAGLE3_DIRNAME, "drafter_tmp", DRAFTER_MANIFEST, MTP_HEAD_FILE):
path = os.path.join(output_dir, name)
if os.path.isdir(path):
shutil.rmtree(path, ignore_errors = True)
elif os.path.isfile(path):
os.remove(path)

View file

@ -262,6 +262,7 @@ def _build_training_worker_config(values: dict[str, Any]) -> dict[str, Any]:
"eval_split": values.get("eval_split"),
"eval_steps": values.get("eval_steps", 0.00),
"early_stopping_patience": values.get("early_stopping_patience", 0),
"drafter": values.get("drafter"),
"dataset_streaming": values.get("dataset_streaming", False),
"dataset_slice_start": values.get("dataset_slice_start"),
"dataset_slice_end": values.get("dataset_slice_end"),
@ -711,6 +712,9 @@ class TrainingProgress:
# The end-of-run record has no step loss, so the progress filter would drop it, and with it the only
# elapsed time that includes the final evaluation, checkpoint save and best-model reload.
is_run_summary: bool = False
# Drafter phase after the fine-tune: {strategy, step, total_steps, loss, top1, state}. Kept apart
# from the SFT fields so its step counter never lands in the SFT loss history.
drafter: Optional[dict] = None
# Marks an omitted mode, which keeps the loaded one; a literal default would overwrite it.
@ -3304,6 +3308,14 @@ class TrainingBackend:
self._progress.status_message = event.get("message", "")
self._progress.is_training = True
elif etype == "drafter_progress":
info = {
k: event.get(k)
for k in ("strategy", "step", "total_steps", "loss", "top1", "state")
}
self._progress.drafter = info
self._progress.is_training = True
elif etype == "warning":
message = event.get("message")
if isinstance(message, str):

View file

@ -5144,6 +5144,20 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) ->
_tqdm_stop.set()
progress = trainer.get_training_progress()
drafter_note = ""
# A Stop during the drafter phase drops only the drafter; the fine-tune is already saved.
stopped_in_training = trainer.should_stop
if config.get("drafter") and not progress.error and not trainer.should_stop:
from core.training.drafter import run_drafter_phase
manifest = run_drafter_phase(trainer, config, output_dir, event_queue, _send_status)
if manifest is not None:
label = "MTP head" if manifest["strategy"] == "mtp" else "EAGLE-3 drafter"
drafter_note = f"\nTrained {label} saved with the model."
progress = trainer.get_training_progress()
elif output_dir:
from core.training.drafter import discard_drafter_artifacts
discard_drafter_artifacts(output_dir)
if progress.error:
event_queue.put(
{
@ -5155,13 +5169,14 @@ def run_training_process(*, event_queue: Any, stop_queue: Any, config: dict) ->
)
else:
saved_output_dir = (
None if trainer.should_stop and not trainer.save_on_stop else output_dir
None if stopped_in_training and not trainer.save_on_stop else output_dir
)
event_queue.put(
{
"type": "complete",
"output_dir": saved_output_dir,
"status_message": progress.status_message or "Training completed",
"status_message": (progress.status_message or "Training completed")
+ drafter_note,
"ts": time.time(),
}
)

View file

@ -117,6 +117,25 @@ def _resolve_inventory_handle(value: str) -> str:
_CHECKPOINTING_OFF = ("false", "0", "no", "none", "off")
class DrafterTrainingConfig(BaseModel):
"""Optional speculative-decoding drafter trained after the fine-tune (omitted or disabled = off)."""
enabled: bool = False
strategy: Literal["auto", "mtp", "eagle3"] = "auto"
data_source: Literal["dataset", "regenerate"] = "dataset"
max_examples: int = Field(2000, ge = 16, le = 100000)
epochs: float = Field(1.0, ge = 0.1, le = 20)
learning_rate: Optional[float] = Field(None, gt = 0, le = 1.0)
draft_steps: int = Field(3, ge = 1, le = 8)
@field_validator("max_examples", "draft_steps", "epochs", mode = "before")
@classmethod
def _reject_booleans(cls, value: Any) -> Any:
if isinstance(value, bool):
raise ValueError("expected a number, not a boolean")
return value
class TrainingStartRequest(BaseModel):
"""Request schema for starting training"""
@ -211,6 +230,10 @@ class TrainingStartRequest(BaseModel):
le = 1000,
description = "Stop after this many evaluations without a lower eval loss and keep the best weights. 0 = off",
)
drafter: Optional[DrafterTrainingConfig] = Field(
None,
description = "Train a speculative-decoding drafter (MTP head or EAGLE-3) after the fine-tune",
)
dataset_slice_start: Optional[int] = Field(
None,
ge = 0,
@ -738,6 +761,17 @@ class TrainingStartRequest(BaseModel):
)
return self
@model_validator(mode = "after")
def _validate_drafter(self) -> "TrainingStartRequest":
drafter = getattr(self, "drafter", None)
if drafter is None or not drafter.enabled:
return self
if getattr(self, "is_embedding", False) or getattr(self, "is_decision", False):
raise ValueError("drafter training is only available for text generation models.")
if getattr(self, "training_type", None) == "Continued Pretraining":
raise ValueError("drafter training is not available for continued pretraining.")
return self
class TrainingJobResponse(BaseModel):
"""Immediate response when training is initiated"""
@ -774,6 +808,7 @@ class TrainingStatus(BaseModel):
"loading_dataset",
"configuring",
"training",
"training_drafter",
"finalizing",
"completed",
"error",

View file

@ -373,6 +373,20 @@ def _export_details(
return {"output_path": output_path}
def _with_drafter(
details: Optional[Dict[str, Any]], output_path: Optional[str]
) -> Optional[Dict[str, Any]]:
"""Adds the trained drafter an export carries (drafter.json), when there is one."""
try:
from core.export.drafter_export import export_drafter_details
drafter = export_drafter_details(output_path)
except Exception:
drafter = None
if drafter is None or details is None:
return details
return {**details, "drafter": drafter}
def _decision_export_details(output_path: Optional[str]) -> Optional[Dict[str, Any]]:
"""A decision GGUF lands in the run folder's gguf/; not registered as a chat model folder."""
if not output_path:
@ -457,7 +471,10 @@ async def export_merged_model(
return ExportOperationResponse(
success = True,
message = message,
details = await asyncio.to_thread(_export_details, output_path, refresh_index = True),
details = _with_drafter(
await asyncio.to_thread(_export_details, output_path, refresh_index = True),
output_path,
),
)
except HTTPException:
raise
@ -518,7 +535,10 @@ async def export_base_model(
return ExportOperationResponse(
success = True,
message = message,
details = await asyncio.to_thread(_export_details, output_path, refresh_index = True),
details = _with_drafter(
await asyncio.to_thread(_export_details, output_path, refresh_index = True),
output_path,
),
)
except HTTPException:
raise
@ -574,7 +594,10 @@ async def export_gguf(
if getattr(backend, "decision", None):
details = await asyncio.to_thread(_decision_export_details, output_path)
else:
details = await asyncio.to_thread(_export_details, output_path, refresh_index = True)
details = _with_drafter(
await asyncio.to_thread(_export_details, output_path, refresh_index = True),
output_path,
)
return ExportOperationResponse(success = True, message = message, details = details)
except HTTPException:
raise
@ -732,7 +755,10 @@ async def export_lora_adapter(
return ExportOperationResponse(
success = True,
message = message,
details = await asyncio.to_thread(_export_details, output_path, refresh_index = True),
details = _with_drafter(
await asyncio.to_thread(_export_details, output_path, refresh_index = True),
output_path,
),
)
except HTTPException:
raise

View file

@ -13715,6 +13715,24 @@ def _estimate_gguf_required_gb(
# guard admit a load that evicts the training run it protects.
total_bytes += LlamaCppBackend._get_gguf_size_bytes(str(f))
_sized_keys.add(_same_file_key(str(f)))
# An EAGLE-3 drafter a Studio export paired with this GGUF (drafter.json): the
# launch attaches it under Auto, so it is charged like the sidecars above.
if main and not _charge_no_drafter and _spec_mode in ("auto", "eagle3"):
try:
from core.export.drafter_export import eagle3_sidecar_path
_eagle3 = eagle3_sidecar_path(str(main))
except Exception:
_eagle3 = None
# Like DSpark / DFlash: a binary without draft-eagle3 never opens it.
if _eagle3:
try:
if not LlamaCppBackend.probe_server_capabilities().get("supports_eagle3"):
_eagle3 = None
except Exception:
_eagle3 = None
if _eagle3 and _same_file_key(_eagle3) not in _sized_keys:
total_bytes += LlamaCppBackend._get_gguf_size_bytes(_eagle3)
_sized_keys.add(_same_file_key(_eagle3))
# A caller owning speculation through llama_extra_args names the drafter with
# --model-draft, which load_model hands to llama-server, so it is charged --

View file

@ -96,6 +96,7 @@ except ImportError:
from utils.paths import is_local_path, normalize_path, resolve_dataset_path
from auth.authentication import authenticated_via_api_key, get_current_subject
from hub.dependencies import get_hf_token
from hub.utils.host_paths import redact_host_paths
from hub.utils.hf_tokens import HfTokenArg, cached_read_refused, hf_token_arg
@ -1683,6 +1684,47 @@ async def get_offload_state(current_subject: str = Depends(get_current_subject))
return {"active": True, **offload}
@router.get("/drafter-capability")
async def get_drafter_capability(
model_name: str,
current_subject: str = Depends(get_current_subject),
via_api_key: bool = Depends(authenticated_via_api_key),
hf_token: Optional[str] = Depends(get_hf_token),
):
"""Which speculative-decoding drafter the Train tab can train for `model_name`: the native MTP head
when the checkpoint ships one, else an EAGLE-3 drafter where vLLM or llama.cpp can run it."""
from core.training.drafter import drafter_capability
from models.inference import resolve_inventory_handle
shown = (model_name or "").strip()
if not shown or len(shown) > 512:
raise HTTPException(status_code = 400, detail = "model_name is required")
# A picker `ref:` handle resolves the way TrainingStartRequest resolves it; the answer keeps the handle.
name = resolve_inventory_handle(shown)
managed = managed_account()
if managed:
# Same rule as the training routes: Hub ids, or paths inside this account's workspace.
try:
account_path(name, reference = True, shared_cache = True)
# A shared-cache snapshot is another tenant's unless this account may see that model.
await asyncio.to_thread(_authorize_cache_fallback, name)
except HTTPException:
return {
"model_name": shown,
"has_native_mtp": False,
"strategy": None,
"supported": False,
"reason": "This model is not available to this account.",
"vllm": False,
"llama_cpp": False,
}
# The caller's own token comes in the header; without one, ambient credentials for the owner
# only, anonymous for API keys and managed accounts (the owner's token must not answer for them).
token = hf_token_arg(hf_token, allow_ambient_token = not (via_api_key is True or managed))
return {**await asyncio.to_thread(drafter_capability, name, token), "model_name": shown}
@router.get("/hardware/visible")
async def get_visible_hardware_utilization(current_subject: str = Depends(get_current_subject)):
from utils.hardware import get_visible_gpu_utilization, gpu_query
@ -1795,6 +1837,13 @@ async def start_training(
to check progress.
"""
await asyncio.to_thread(_validate_decision_request, request, via_api_key)
if request.drafter is not None and request.drafter.enabled:
from core.training.training import should_use_mlx_training_backend
if should_use_mlx_training_backend():
raise HTTPException(
status_code = 400,
detail = "Drafter training is not available on Apple Silicon (MLX) yet.",
)
if managed_account():
from utils.paths import tensorboard_root
@ -2119,6 +2168,11 @@ async def start_training(
"eval_split": request.eval_split,
"eval_steps": request.eval_steps,
"early_stopping_patience": request.early_stopping_patience,
"drafter": (
request.drafter.model_dump()
if request.drafter is not None and request.drafter.enabled
else None
),
"dataset_slice_start": request.dataset_slice_start,
"dataset_slice_end": request.dataset_slice_end,
"custom_format_mapping": request.custom_format_mapping,
@ -2646,7 +2700,9 @@ def _build_training_status(
phase = "error"
elif is_active:
msg_lower = status_message.lower()
if "loading" in msg_lower or "importing" in msg_lower:
if msg_lower.startswith("training drafter"):
phase = "training_drafter"
elif "loading" in msg_lower or "importing" in msg_lower:
phase = "loading_model"
elif any(k in msg_lower for k in ["preparing", "initializing", "configuring"]):
phase = "configuring"
@ -2670,6 +2726,7 @@ def _build_training_status(
"loss": getattr(progress, "loss", None),
"learning_rate": getattr(progress, "learning_rate", None),
"output_dir": getattr(backend, "_output_dir", None) or None,
"drafter": getattr(progress, "drafter", None),
"model_download_repo_id": (
getattr(backend, "_model_download_repo_id", None) if is_active else None
),

View file

@ -1545,6 +1545,15 @@ class TestEstimateGgufRequiredGb(unittest.TestCase):
classmethod(lambda cls, binary = None: {"supports_dspark": supported}),
)
@staticmethod
def _eagle3_capable(supported):
from core.inference.llama_cpp import LlamaCppBackend
return patch.object(
LlamaCppBackend,
"probe_server_capabilities",
classmethod(lambda cls, binary = None: {"supports_eagle3": supported}),
)
def test_local_dspark_sidecar_is_only_counted_when_requested(self):
import tempfile
@ -1584,6 +1593,44 @@ class TestEstimateGgufRequiredGb(unittest.TestCase):
self.assertAlmostEqual(dspark_gb, 5000 / (1024**3), places = 9)
self.assertAlmostEqual(extras_gb, 5000 / (1024**3), places = 9)
def test_local_eagle3_drafter_paired_by_the_export_is_charged(self):
"""A Studio export's drafter.json pairs an EAGLE-3 GGUF that Auto attaches at launch."""
import json
import tempfile
with tempfile.TemporaryDirectory() as d:
p = Path(d)
target = p / "model.gguf"
target.write_bytes(b"x" * 2000)
(p / "eagle3-model-F16.gguf").write_bytes(b"y" * 3000)
cfg = SimpleNamespace(
gguf_file = str(target),
gguf_mmproj_file = None,
gguf_mtp_file = None,
gguf_hf_repo = None,
gguf_variant = None,
)
with (
patch.object(self.route, "_estimate_gguf_kv_gb", return_value = 0.0),
patch.object(self.route, "_remote_gguf_compute_reserve_gb", return_value = 0.0),
):
with self._eagle3_capable(True):
bare = self.route._estimate_gguf_required_gb(cfg, speculative_type = "auto")
(p / "drafter.json").write_text(
json.dumps({"strategy": "eagle3", "files": ["eagle3-model-F16.gguf"]})
)
auto = self.route._estimate_gguf_required_gb(cfg, speculative_type = "auto")
off = self.route._estimate_gguf_required_gb(cfg, speculative_type = "off")
# A binary without draft-eagle3 never opens the sidecar.
with self._eagle3_capable(False):
incapable = self.route._estimate_gguf_required_gb(
cfg, speculative_type = "eagle3"
)
self.assertAlmostEqual(bare, 2000 / (1024**3), places = 9)
self.assertAlmostEqual(auto, 5000 / (1024**3), places = 9)
self.assertAlmostEqual(off, 2000 / (1024**3), places = 9)
self.assertAlmostEqual(incapable, 2000 / (1024**3), places = 9)
def test_forced_dspark_on_an_incapable_binary_charges_no_drafter_at_all(self):
"""The loader's DSpark branch falls back to --spec-default, which loads no
drafter, so charging the MTP one would refuse a load that fits. Auto is

View file

@ -0,0 +1,178 @@
# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
"""llama-server flags for a drafter trained with the fine-tune: an EAGLE-3 sidecar paired by the export's
drafter.json, and a trained MTP head that lifts Auto's sub-3B / MLA defaults. GGUFs without that manifest
keep today's routing."""
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent))
from test_llama_cpp_mtp_detection import ( # noqa: F401 (stubs are installed on import)
LlamaCppBackend,
_canonicalize_spec_mode,
_flags_dict,
)
_SUB_3B = "unsloth/Qwen3.5-0.8B-MTP-GGUF"
_PLAIN = "unsloth/Qwen3-4B-Instruct-GGUF"
def _backend(monkeypatch, *, eagle3 = True):
caps = {
"found": True,
"mtp_token": "draft-mtp",
"supports_mtp": True,
"supports_dspark": True,
"supports_dflash": True,
"supports_eagle3": eagle3,
"mtp_probe_inconclusive": False,
"ngram_mod_flavor": "new",
"supports_ngram_mod": True,
"spec_draft_n_max_flag": "--spec-draft-n-max",
}
monkeypatch.setattr(
LlamaCppBackend, "probe_server_capabilities", classmethod(lambda cls, binary = None: caps)
)
backend = LlamaCppBackend()
backend._nextn_predict_layers = None
return backend
def _flags(
backend,
mode,
model,
path,
gpus = True,
):
return backend._build_speculative_flags(
speculative_type = mode,
spec_draft_n_max = None,
extra_args = None,
model_identifier = model,
model_path = path,
gpus = gpus,
binary = "/fake/llama-server",
)
def _export(
tmp_path,
info,
name = "model.Q4_K_M.gguf",
):
gguf = tmp_path / name
gguf.write_bytes(b"GGUF")
(tmp_path / "drafter.json").write_text(json.dumps(info))
return str(gguf)
def test_eagle3_mode_canonicalizes():
assert _canonicalize_spec_mode("eagle3") == "eagle3"
assert _canonicalize_spec_mode("draft-eagle3") == "eagle3"
def test_auto_uses_paired_eagle3_sidecar(monkeypatch, tmp_path):
(tmp_path / "eagle3-model-F16.gguf").write_bytes(b"GGUF")
path = _export(
tmp_path,
{"strategy": "eagle3", "files": ["eagle3-model-F16.gguf"], "ggufs": ["model.Q4_K_M.gguf"]},
)
flags = _flags_dict(_flags(_backend(monkeypatch), "auto", _PLAIN, path))
assert flags["--spec-type"] == "draft-eagle3"
assert flags["--model-draft"] == str(tmp_path / "eagle3-model-F16.gguf")
assert flags["--spec-draft-n-max"] == "3"
def test_eagle3_without_binary_support_falls_back(monkeypatch, tmp_path):
(tmp_path / "eagle3-model-F16.gguf").write_bytes(b"GGUF")
path = _export(tmp_path, {"strategy": "eagle3", "files": ["eagle3-model-F16.gguf"]})
auto = _flags(_backend(monkeypatch, eagle3 = False), "auto", _PLAIN, path)
assert "draft-eagle3" not in auto
forced = _flags(_backend(monkeypatch, eagle3 = False), "eagle3", _PLAIN, path)
assert forced == ["--spec-default"]
def test_forced_eagle3_without_sidecar_falls_back(monkeypatch, tmp_path):
backend = _backend(monkeypatch)
assert _flags(backend, "eagle3", _PLAIN, str(tmp_path / "x.gguf")) == ["--spec-default"]
assert backend._spec_fallback_reason == "drafter_not_found"
def test_manifest_naming_another_gguf_is_ignored(monkeypatch, tmp_path):
(tmp_path / "eagle3-model-F16.gguf").write_bytes(b"GGUF")
_export(
tmp_path,
{"strategy": "eagle3", "files": ["eagle3-model-F16.gguf"], "ggufs": ["other.gguf"]},
)
path = str(tmp_path / "model.Q4_K_M.gguf")
assert "draft-eagle3" not in _flags(_backend(monkeypatch), "auto", _PLAIN, path)
@pytest.mark.parametrize("gpus", [True, False])
def test_trained_mtp_head_lifts_the_sub_3b_floor(monkeypatch, tmp_path, gpus):
stock = _flags_dict(_flags(_backend(monkeypatch), "auto", _SUB_3B, None, gpus))
assert stock.get("--spec-type") == "ngram-mod" # unchanged without a manifest
path = _export(
tmp_path,
{"strategy": "mtp", "mtp_trained": True, "files": [], "ggufs": ["model.Q4_K_M.gguf"]},
)
trained = _flags_dict(_flags(_backend(monkeypatch), "auto", _SUB_3B, path, gpus))
assert "draft-mtp" in trained["--spec-type"]
def test_trained_mtp_head_lifts_the_mla_default_off(monkeypatch, tmp_path):
backend = _backend(monkeypatch)
backend._nextn_predict_layers = 1
backend._kv_lora_rank = 512
monkeypatch.delenv("UNSLOTH_MLA_MTP_ENABLED", raising = False)
model = "unsloth/GLM-4.7-Flash-GGUF"
stock = _flags_dict(_flags(backend, "auto", model, None))
assert stock.get("--spec-type") != "draft-mtp"
path = _export(tmp_path, {"strategy": "mtp", "mtp_trained": True, "files": []})
trained = _flags_dict(_flags(backend, "auto", model, path))
assert trained["--spec-type"] == "draft-mtp"
def test_no_manifest_keeps_routing(monkeypatch, tmp_path):
path = str(tmp_path / "model.Q4_K_M.gguf")
for mode in ("auto", "mtp", "ngram", "off", "dspark"):
a = _flags(_backend(monkeypatch), mode, _SUB_3B, None)
b = _flags(_backend(monkeypatch), mode, _SUB_3B, path)
assert a == b, mode
def test_exported_draft_steps_set_the_default_depth(monkeypatch, tmp_path):
(tmp_path / "eagle3-model-F16.gguf").write_bytes(b"GGUF")
path = _export(
tmp_path,
{"strategy": "eagle3", "files": ["eagle3-model-F16.gguf"], "draft_steps": 5},
)
assert (
_flags_dict(_flags(_backend(monkeypatch), "auto", _PLAIN, path))["--spec-draft-n-max"]
== "5"
)
override = _backend(monkeypatch)._build_speculative_flags(
speculative_type = "auto",
spec_draft_n_max = 2,
extra_args = None,
model_identifier = _PLAIN,
model_path = path,
gpus = True,
binary = "/fake/llama-server",
)
assert _flags_dict(override)["--spec-draft-n-max"] == "2"
mtp = tmp_path / "mtp"
mtp.mkdir()
path = _export(mtp, {"strategy": "mtp", "mtp_trained": True, "files": [], "draft_steps": 4})
assert (
_flags_dict(_flags(_backend(monkeypatch), "auto", _SUB_3B, path))["--spec-draft-n-max"]
== "4"
)

File diff suppressed because it is too large Load diff

View file

@ -2547,3 +2547,45 @@ def test_vllm_mamba_retry_respects_an_external_stop(isolated, monkeypatch):
engine.start("model", 4096, [0], dict(os.environ))
engine.stop()
assert len(launches) == 1
@_LOCAL_ENGINE_HOST
def test_vllm_retries_once_without_a_drafter_that_breaks_startup(isolated, monkeypatch):
import os
from core.inference import engine_adapters
monkeypatch.setattr(
engine_adapters, "vllm_speculative_args", lambda _: ["--speculative-config", "{}"]
)
engine, launches = _scripted_engine(isolated, monkeypatch, ["crash", "serve"])
try:
engine.start("model", 4096, [0], dict(os.environ))
assert engine.phase == "ready"
finally:
engine.stop()
assert [launch.get("no_speculative_config") for launch in launches] == [None, True]
@_LOCAL_ENGINE_HOST
def test_vllm_drafter_retry_respects_an_external_stop(isolated, monkeypatch):
import os
from core.inference import engine_adapters, managed_engine
monkeypatch.setattr(
engine_adapters, "vllm_speculative_args", lambda _: ["--speculative-config", "{}"]
)
engine, launches = _scripted_engine(isolated, monkeypatch, ["crash", "serve"])
original = engine._start_once
def first_then_stop(*args):
try:
return original(*args)
except managed_engine._EngineExited:
assert engine.stop() is True
raise
monkeypatch.setattr(engine, "_start_once", first_then_stop)
with pytest.raises(RuntimeError, match = "cancelled"):
engine.start("model", 4096, [0], dict(os.environ))
engine.stop()
assert len(launches) == 1

View file

@ -160,8 +160,11 @@ export const BUDGET = {
// raw / 83 B transfer to the eager settings and English locale chunks (6,780,553 -> 6,780,901 B raw,
// 2,038,848 -> 2,038,931 B transfer against 9f5b133254, one machine and build, Node 24, chunk count unchanged
// at 95). Raised by that growth.
transferBytes: 2_039_292,
rawBytes: 6_782_356,
// Drafter training in the Train tab adds about 4.9 KB raw / 1.4 KB transfer (6,628.3 KB raw, 1,992.9 KB transfer
// measured against cd424654a, one machine and build, 95 chunks): its persisted training-store fields, their
// migration and the en strings hydrate at startup; the section itself is not in the startup set.
transferBytes: 2_041_000,
rawBytes: 6_787_500,
};
// The chunk count is reported but not budgeted. Splitting a page out of the entry raises it while lowering the

View file

@ -169,6 +169,13 @@ export const DEFAULT_HYPERPARAMS = {
saveSteps: 0,
evalSteps: 0.0,
earlyStoppingPatience: 0,
drafterEnabled: false,
drafterStrategy: "auto" as const,
drafterDataSource: "dataset" as const,
drafterMaxExamples: 2000,
drafterEpochs: 1,
drafterLearningRate: null as number | null,
drafterDraftSteps: 3,
packing: false,
trainOnCompletions: false,
gradientCheckpointing: "unsloth" as const,

View file

@ -89,6 +89,14 @@ export interface ExportOperationResponse {
details?: { output_path?: string | null } & Record<string, unknown>;
}
/** The trained speculative-decoding drafter an export carried (backend `details.drafter`). */
export interface ExportDrafterInfo {
strategy: "mtp" | "eagle3";
files?: string[];
vllm_command?: string | null;
llama_cpp_args?: string[] | string | null;
}
/** GGUF export options for a decision model (Clef / Laya) checkpoint folder. */
export interface DecisionExportInfo {
is_decision: boolean;

View file

@ -30,7 +30,7 @@ import {
import { HugeiconsIcon } from "@hugeicons/react";
import { useEffect, useRef, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import type { ExportLogEntry } from "../api/export-api";
import type { ExportDrafterInfo, ExportLogEntry } from "../api/export-api";
import {
EXPORT_METHODS,
type ExportMethod,
@ -456,6 +456,9 @@ export function ExportRunPanel(props: ExportRunPanelProps) {
</div>
));
})()}
{run.result?.drafter ? (
<ExportDrafterNote drafter={run.result.drafter} />
) : null}
</div>
</div>
)}
@ -650,3 +653,21 @@ export function ExportRunPanel(props: ExportRunPanelProps) {
</div>
);
}
function ExportDrafterNote({ drafter }: { drafter: ExportDrafterInfo }) {
return (
<div className="flex min-w-0 flex-col gap-0.5">
<span>
Includes trained drafter ({drafter.strategy === "mtp" ? "MTP" : "EAGLE-3"})
</span>
{drafter.vllm_command ? (
<code
className="select-all whitespace-pre-wrap break-all font-mono text-ui-12 text-foreground/90"
title={drafter.vllm_command}
>
{drafter.vllm_command}
</code>
) : null}
</div>
);
}

View file

@ -11,6 +11,7 @@ import {
getExportStatus,
isRecoverableTransportError,
loadCheckpoint,
type ExportDrafterInfo,
type ExportLogEntry,
type ExportLogPollEntry,
type ExportOperationResponse,
@ -18,6 +19,18 @@ import {
} from "../api/export-api";
import type { ExportMethod } from "../constants";
// Here, not in export-api: tests swap that module for a stub of its request functions.
function exportDrafterFrom(
details: ExportOperationResponse["details"] | undefined,
): ExportDrafterInfo | null {
const drafter = details?.drafter;
if (typeof drafter !== "object" || drafter === null) return null;
const strategy = (drafter as Record<string, unknown>).strategy;
return strategy === "mtp" || strategy === "eagle3"
? (drafter as ExportDrafterInfo)
: null;
}
/** Thrown by status recovery when the backend reports the op was cancelled. */
class ExportCanceledError extends Error {
constructor() {
@ -205,6 +218,8 @@ interface ExportRuntimeState {
outputPath: string | null;
outputPaths: { label: string; path: string }[];
destination: ExportDestination;
/** Set when the export carried a trained drafter (MTP head or EAGLE-3). */
drafter?: ExportDrafterInfo | null;
} | null;
error: string | null;
cancelRequested: boolean;
@ -479,6 +494,7 @@ export const useExportRuntimeStore = create<ExportRuntimeStore>()((set, get) =>
// banner can list each sibling directory a multi-format run created.
set({ phase: "exporting" });
const outputs: { label: string; path: string }[] = [];
let drafter: ExportDrafterInfo | null = null;
if (params.exportMethod === "merged") {
// Each selected format writes its own sibling directory (PEFT or non-PEFT base alike).
@ -490,7 +506,7 @@ export const useExportRuntimeStore = create<ExportRuntimeStore>()((set, get) =>
if (!isCurrent()) return;
set({ quantIndex: i });
const sel = selections[i];
const { outputPath } = await runRecoverableOp(() =>
const { outputPath, details: mergedDetails } = await runRecoverableOp(() =>
exportMerged({
save_directory: params.saveDirectory,
format_type: sel.formatType,
@ -505,6 +521,7 @@ export const useExportRuntimeStore = create<ExportRuntimeStore>()((set, get) =>
}),
);
if (outputPath) outputs.push({ label: sel.label, path: outputPath });
drafter = exportDrafterFrom(mergedDetails) ?? drafter;
if (!isCurrent()) return;
set({ quantIndex: i + 1 });
}
@ -538,6 +555,7 @@ export const useExportRuntimeStore = create<ExportRuntimeStore>()((set, get) =>
path: outputPath,
});
}
drafter = exportDrafterFrom(details);
if (outputPath && params.npuQ4nx) {
const sep = outputPath.includes("\\") ? "\\" : "/";
outputs.push({ label: "AMD NPU (Q4NX)", path: `${outputPath}${sep}npu-q4nx` });
@ -545,7 +563,7 @@ export const useExportRuntimeStore = create<ExportRuntimeStore>()((set, get) =>
if (!isCurrent()) return;
set({ quantIndex: get().quantTotal });
} else if (params.exportMethod === "lora") {
const { outputPath } = await runRecoverableOp(() =>
const { outputPath, details: loraDetails } = await runRecoverableOp(() =>
exportLoRA({
save_directory: params.saveDirectory,
push_to_hub: pushToHub,
@ -565,6 +583,7 @@ export const useExportRuntimeStore = create<ExportRuntimeStore>()((set, get) =>
path: outputPath,
});
}
drafter = exportDrafterFrom(loraDetails);
}
if (!isCurrent()) return;
@ -576,6 +595,7 @@ export const useExportRuntimeStore = create<ExportRuntimeStore>()((set, get) =>
outputPath: outputs[0]?.path ?? null,
outputPaths: outputs,
destination: params.destination,
drafter,
},
});
} catch (err) {

View file

@ -0,0 +1,195 @@
// 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 { Checkbox } from "@/components/ui/checkbox";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { usePlatformStore } from "@/config/env";
import {
drafterEligible,
useDrafterCapability,
useTrainingConfigStore,
} from "@/features/training";
import { useT } from "@/i18n";
import { ChevronDownStandardIcon } from "@/lib/chevron-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { type ReactElement, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { ParamsRow } from "./params-section-controls";
export function DrafterSection(): ReactElement | null {
const t = useT();
const deviceType = usePlatformStore((state) => state.deviceType);
const store = useTrainingConfigStore(
useShallow((state) => ({
selectedModel: state.selectedModel,
eligible: drafterEligible(state),
capability: state.drafterCapability,
enabled: state.drafterEnabled,
dataSource: state.drafterDataSource,
maxExamples: state.drafterMaxExamples,
epochs: state.drafterEpochs,
learningRate: state.drafterLearningRate,
draftSteps: state.drafterDraftSteps,
setEnabled: state.setDrafterEnabled,
setDataSource: state.setDrafterDataSource,
setMaxExamples: state.setDrafterMaxExamples,
setEpochs: state.setDrafterEpochs,
setLearningRate: state.setDrafterLearningRate,
setDraftSteps: state.setDrafterDraftSteps,
})),
);
const [open, setOpen] = useState(false);
useDrafterCapability(open || store.enabled);
// MLX trains through its own worker, which has no drafter phase.
if (!store.eligible || deviceType === "mac") {
return null;
}
const capability =
store.capability && store.capability.model_name === store.selectedModel
? store.capability
: null;
const supported = capability?.supported === true;
const helper = !capability
? t("studio.params.drafterChecking")
: !supported
? t("studio.params.drafterUnavailable", {
reason: capability.reason ?? "",
})
: capability.has_native_mtp
? t("studio.params.drafterRetrainMtp")
: t("studio.params.drafterEagle3");
return (
<Collapsible open={open} onOpenChange={setOpen}>
<CollapsibleTrigger className="flex w-full cursor-pointer items-center gap-1.5 text-xs text-muted-foreground">
<HugeiconsIcon
icon={ChevronDownStandardIcon}
className={`size-3.5 transition-transform ${open ? "rotate-180" : ""}`}
/>
{t("studio.params.drafterSettings")}
</CollapsibleTrigger>
<CollapsibleContent className="mt-3 data-[state=open]:overflow-visible">
<div className="pt-1.5 flex flex-col gap-4">
<div className="flex flex-col gap-1">
<ParamsRow
label={t("studio.params.drafterTrain")}
tooltip={t("studio.params.drafterTrainTooltip")}
>
<Checkbox
id="drafterEnabled"
aria-label={t("studio.params.drafterTrain")}
checked={store.enabled && supported}
disabled={!supported}
onCheckedChange={(value) => store.setEnabled(!!value)}
/>
</ParamsRow>
<span
className="text-xs text-muted-foreground"
data-testid="drafter-helper"
>
{helper}
</span>
</div>
{store.enabled && supported && (
<>
<ParamsRow label={t("studio.params.drafterDataSource")}>
<Select
value={store.dataSource}
onValueChange={(value) =>
store.setDataSource(
value === "regenerate" ? "regenerate" : "dataset",
)
}
>
<SelectTrigger className="w-56">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="dataset">
{t("studio.params.drafterDataSourceDataset")}
</SelectItem>
<SelectItem value="regenerate">
{t("studio.params.drafterDataSourceRegenerate")}
</SelectItem>
</SelectContent>
</Select>
</ParamsRow>
<ParamsRow label={t("studio.params.drafterMaxExamples")}>
<Input
type="number"
step="100"
min="16"
max="100000"
value={store.maxExamples}
onChange={(event) =>
store.setMaxExamples(Number(event.target.value))
}
className="w-28 font-mono"
/>
</ParamsRow>
<ParamsRow label={t("studio.params.drafterEpochs")}>
<Input
type="number"
step="0.5"
min="0.1"
max="20"
value={store.epochs}
onChange={(event) =>
store.setEpochs(Number(event.target.value))
}
className="w-28 font-mono"
/>
</ParamsRow>
<ParamsRow label={t("studio.params.drafterLearningRate")}>
<Input
type="number"
step="0.00001"
min="0"
placeholder={t("studio.params.drafterLearningRateDefault")}
value={store.learningRate ?? ""}
onChange={(event) => {
const raw = event.target.value.trim();
const value = Number(raw);
store.setLearningRate(
raw === "" || !Number.isFinite(value) || value <= 0
? null
: value,
);
}}
className="w-28 font-mono"
/>
</ParamsRow>
<ParamsRow label={t("studio.params.drafterDraftSteps")}>
<Input
type="number"
step="1"
min="1"
max="8"
value={store.draftSteps}
onChange={(event) =>
store.setDraftSteps(Number(event.target.value))
}
className="w-28 font-mono"
/>
</ParamsRow>
</>
)}
</div>
</CollapsibleContent>
</Collapsible>
);
}

View file

@ -18,6 +18,7 @@ import {
} from "@/components/ui/tooltip";
import { CONTEXT_LENGTHS } from "@/config/training";
import {
useDrafterCapability,
useMaxStepsEpochsToggle,
useTrainingConfigStore,
} from "@/features/training";
@ -26,6 +27,7 @@ import { InformationCircleIcon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { type ReactElement, useRef, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { DrafterSection } from "./drafter-section";
import { FieldHint } from "./field-hint";
import { LoraParamsSection } from "./lora-params-section";
import { TrainingHyperparametersSection } from "./training-hyperparameters-section";
@ -47,6 +49,9 @@ export function ParamsSection({
mode?: "simple" | "advanced";
}): ReactElement {
const t = useT();
// Simple mode hides the section, but a drafter turned on earlier still needs its capability.
const drafterEnabled = useTrainingConfigStore((state) => state.drafterEnabled);
useDrafterCapability(drafterEnabled);
const store = useTrainingConfigStore(
useShallow((state) => ({
projectName: state.projectName,
@ -487,6 +492,7 @@ export function ParamsSection({
epochsSliderMax={epochsSliderMax}
/>
)}
{showAdvanced && <DrafterSection />}
</div>
</div>
);

View file

@ -19,6 +19,8 @@ export const phaseColors: Record<TrainingPhase, string> = {
// Busy, not done: amber like the other working states, never completed green.
finalizing:
"bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300",
training_drafter:
"bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300",
completed:
"bg-emerald-100 text-emerald-700 dark:bg-emerald-900 dark:text-emerald-300",
error: "bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300",

View file

@ -80,6 +80,7 @@ const phaseLabelKeys = {
configuring: "studio.progress.phase.configuring",
training: "studio.progress.phase.training",
finalizing: "studio.progress.phase.finalizing",
training_drafter: "studio.progress.phase.trainingDrafter",
completed: "studio.progress.phase.completed",
error: "studio.progress.phase.error",
stopped: "studio.progress.phase.stopped",

View file

@ -6,6 +6,7 @@ import {
toBackendTrainingType,
} from "../lib/training-methods";
import type { SystemInfoResponse } from "@/hooks/use-system";
import { drafterEligible, drafterRequestFrom } from "../lib/drafter-config";
import type { TrainingStartRequest } from "../types/api";
import type { TrainingConfigState } from "../types/config";
@ -318,5 +319,30 @@ export function buildTrainingStartPayload(
tensorboard_dir: config.enableTensorboard
? config.tensorboardDir.trim() || null
: null,
// Spread only when sent: a run without a drafter posts exactly the fields it did before.
...(sendsDrafter(config, system)
? { drafter: drafterRequestFrom(config) }
: {}),
};
}
/** On, a run type with a drafter phase, not MLX, and not a model the backend said cannot train one.
* A capability check still pending (or for a previous model) sends it: the backend re-checks the
* model before the drafter phase and reports a warning instead of dropping the request silently. */
export function sendsDrafter(
config: TrainingConfigState,
system: SystemInfoResponse | null = null,
): boolean {
const capability = config.drafterCapability;
const refused =
capability !== null &&
capability.model_name === config.selectedModel &&
!capability.supported &&
!capability.inconclusive;
return (
config.drafterEnabled &&
drafterEligible(config) &&
system?.device_backend !== "mlx" &&
!refused
);
}

View file

@ -29,6 +29,8 @@ interface BackendTrainingDefaults {
save_steps?: number;
eval_steps?: number;
early_stopping_patience?: number;
/** Drafter training block (same shape as the start request's `drafter`). */
drafter?: unknown;
weight_decay?: number;
random_seed?: number;
offload_layers?: number | "auto";

View file

@ -2,6 +2,7 @@
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import { authFetch } from "@/features/auth";
import { hubTokenHeader } from "@/features/hub";
import { readFastApiError } from "@/lib/format-fastapi-error";
// eslint-disable-next-line no-restricted-imports
import { checkDiskSpace } from "@/features/settings/low-disk-check";
@ -12,6 +13,7 @@ import {
consumeTrainingProgressStream,
} from "../lib/training-sse-stream";
import type {
DrafterCapability,
TrainingResetResponse,
TrainingStartRequest,
TrainingStartRequestStatusResponse,
@ -368,6 +370,23 @@ export function getTrainingMetrics(
: requestTrainingMetrics.run(scope, expectedJobId);
}
/** Which drafter `modelName` can train after fine-tuning (MTP retrain or EAGLE-3) and where it runs. */
export async function getDrafterCapability(
modelName: string,
signal?: AbortSignal,
hfToken?: string | null,
): Promise<DrafterCapability> {
// The token rides a header, never the query string (private / gated models).
const response = await authFetch(
`/api/train/drafter-capability?model_name=${encodeURIComponent(modelName)}`,
{ signal, headers: hubTokenHeader(hfToken?.trim() || null) },
);
if (!response.ok) {
throw new Error(await readFastApiError(response));
}
return (await response.json()) as DrafterCapability;
}
export async function streamTrainingProgress(options: {
signal: AbortSignal;
expectedJobId: string;

View file

@ -0,0 +1,91 @@
// 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 { useEffect, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { useHfTokenStore } from "@/features/hub";
import { getDrafterCapability } from "../api/train-api";
import { useTrainingConfigStore } from "../stores/training-config-store";
/** Keep `drafterCapability` current for the selected model while drafter training is on.
* Mounted with the params section in both modes: the start payload only carries `drafter`
* once the capability for this exact model says it can train one. */
// The last probe, shared by every mounted copy of the hook: one request per (model, token), and a
// failure is retried while the hook stays active, never cached as final.
let lastProbe: { key: string; failed: boolean; done: boolean } | null = null;
const RETRY_MS = 15_000;
export function useDrafterCapability(active: boolean): void {
const { selectedModel, capabilityFor, setDrafterCapability } =
useTrainingConfigStore(
useShallow((state) => ({
selectedModel: state.selectedModel,
capabilityFor: state.drafterCapability?.model_name ?? null,
setDrafterCapability: state.setDrafterCapability,
})),
);
const token = useHfTokenStore((state) => state.token);
const [retry, setRetry] = useState(0);
useEffect(() => {
if (!active) return;
const id = setInterval(() => {
if (lastProbe?.failed) {
lastProbe = null;
setRetry((n) => n + 1);
}
}, RETRY_MS);
return () => clearInterval(id);
}, [active]);
useEffect(() => {
const key = `${selectedModel}\u0000${token}`;
if (!active) {
if (lastProbe?.failed) lastProbe = null;
return;
}
if (
!selectedModel ||
(capabilityFor === selectedModel && lastProbe?.key === key)
)
return;
lastProbe = { key, failed: false, done: false };
const controller = new AbortController();
getDrafterCapability(selectedModel, controller.signal, token)
.then((capability) => {
if (controller.signal.aborted) return;
lastProbe = {
key,
failed: Boolean(capability.inconclusive),
done: true,
};
// The backend may echo a resolved path; key the answer on what was asked.
setDrafterCapability({ ...capability, model_name: selectedModel });
})
.catch((error: unknown) => {
if (controller.signal.aborted) return;
lastProbe = { key, failed: true, done: true };
setDrafterCapability({
model_name: selectedModel,
has_native_mtp: false,
strategy: null,
supported: false,
reason: error instanceof Error ? error.message : String(error),
vllm: false,
llama_cpp: false,
});
});
return () => {
controller.abort();
// An answer still in flight was dropped: let the next run ask again.
if (lastProbe?.key === key && !lastProbe.done) lastProbe = null;
};
}, [
active,
selectedModel,
capabilityFor,
token,
setDrafterCapability,
retry,
]);
}

View file

@ -35,6 +35,8 @@ export {
useTrainingUnloadGuard,
} from "./hooks/use-training-unload-guard";
export { useMaxStepsEpochsToggle } from "./hooks/use-max-steps-epochs-toggle";
export { useDrafterCapability } from "./hooks/use-drafter-capability";
export { drafterEligible } from "./lib/drafter-config";
export { HfDatasetSubsetSplitSelectors } from "./components/hf-dataset-subset-split-selectors";
export { useDatasetPreviewDialogStore } from "./stores/dataset-preview-dialog-store";
export {

View file

@ -0,0 +1,137 @@
// 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 { DEFAULT_HYPERPARAMS } from "@/config/training";
import type { DrafterTrainingRequest } from "../types/api";
import type {
DrafterDataSource,
DrafterStrategy,
TrainingConfigState,
} from "../types/config";
export type DrafterState = Pick<
TrainingConfigState,
| "drafterEnabled"
| "drafterStrategy"
| "drafterDataSource"
| "drafterMaxExamples"
| "drafterEpochs"
| "drafterLearningRate"
| "drafterDraftSteps"
>;
export const DRAFTER_DEFAULTS: DrafterState = {
drafterEnabled: DEFAULT_HYPERPARAMS.drafterEnabled,
drafterStrategy: DEFAULT_HYPERPARAMS.drafterStrategy,
drafterDataSource: DEFAULT_HYPERPARAMS.drafterDataSource,
drafterMaxExamples: DEFAULT_HYPERPARAMS.drafterMaxExamples,
drafterEpochs: DEFAULT_HYPERPARAMS.drafterEpochs,
drafterLearningRate: DEFAULT_HYPERPARAMS.drafterLearningRate,
drafterDraftSteps: DEFAULT_HYPERPARAMS.drafterDraftSteps,
};
// Backend bounds (models/training.py DrafterTrainingConfig): a half-typed field must not 422 the Start.
const MAX_EXAMPLES = [16, 100000] as const;
const EPOCHS = [0.1, 20] as const;
const DRAFT_STEPS = [1, 8] as const;
function clamp(value: number, [lo, hi]: readonly [number, number]): number {
return Math.min(Math.max(value, lo), hi);
}
export function isDrafterStrategy(value: unknown): value is DrafterStrategy {
return value === "auto" || value === "mtp" || value === "eagle3";
}
export function isDrafterDataSource(
value: unknown,
): value is DrafterDataSource {
return value === "dataset" || value === "regenerate";
}
function finiteOr(value: unknown, fallback: number): number {
const n = Number(value);
return value !== null && value !== "" && Number.isFinite(n) ? n : fallback;
}
export function normalizeDrafterMaxExamples(value: unknown): number {
return Math.floor(
clamp(finiteOr(value, DRAFTER_DEFAULTS.drafterMaxExamples), MAX_EXAMPLES),
);
}
export function normalizeDrafterEpochs(value: unknown): number {
return clamp(finiteOr(value, DRAFTER_DEFAULTS.drafterEpochs), EPOCHS);
}
export function normalizeDrafterDraftSteps(value: unknown): number {
return Math.floor(
clamp(finiteOr(value, DRAFTER_DEFAULTS.drafterDraftSteps), DRAFT_STEPS),
);
}
export function normalizeDrafterLearningRate(value: unknown): number | null {
const n = Number(value);
return value !== null && value !== "" && Number.isFinite(n) && n > 0
? n
: null;
}
/** State from a saved run's / YAML file's `drafter` block; absent or malformed = feature off. */
export function drafterStateFrom(value: unknown): DrafterState {
if (typeof value !== "object" || value === null)
return { ...DRAFTER_DEFAULTS };
const v = value as Record<string, unknown>;
return {
drafterEnabled: v.enabled === true,
drafterStrategy: isDrafterStrategy(v.strategy)
? v.strategy
: DRAFTER_DEFAULTS.drafterStrategy,
drafterDataSource: isDrafterDataSource(v.data_source)
? v.data_source
: DRAFTER_DEFAULTS.drafterDataSource,
drafterMaxExamples: normalizeDrafterMaxExamples(v.max_examples),
drafterEpochs: normalizeDrafterEpochs(v.epochs),
drafterLearningRate: normalizeDrafterLearningRate(v.learning_rate),
drafterDraftSteps: normalizeDrafterDraftSteps(v.draft_steps),
};
}
/** The request block for these settings, enabled or not (YAML keeps the user's settings either way). */
export function drafterRequestFrom(
state: DrafterState,
): DrafterTrainingRequest {
return {
enabled: state.drafterEnabled,
strategy: state.drafterStrategy,
data_source: state.drafterDataSource,
max_examples: normalizeDrafterMaxExamples(state.drafterMaxExamples),
epochs: normalizeDrafterEpochs(state.drafterEpochs),
learning_rate: normalizeDrafterLearningRate(state.drafterLearningRate),
draft_steps: normalizeDrafterDraftSteps(state.drafterDraftSteps),
};
}
/** Runs the backend's drafter phase cannot take: embeddings, decision models, CPT, and image / audio fine-tunes. */
export function drafterEligible(
config: Pick<
TrainingConfigState,
| "modelType"
| "isEmbeddingModel"
| "trainingMethod"
| "isVisionModel"
| "isDatasetImage"
| "isAudioModel"
| "isDatasetAudio"
>,
): boolean {
return (
config.modelType !== "decision" &&
config.modelType !== "embeddings" &&
!config.isEmbeddingModel &&
config.trainingMethod !== "cpt" &&
// A vision / audio model on a text dataset trains the text path, which the drafter supports.
!(config.isVisionModel && config.isDatasetImage === true) &&
!(config.isAudioModel && config.isDatasetAudio)
);
}

View file

@ -4,6 +4,7 @@
import * as yaml from "js-yaml";
import type { BackendModelConfig } from "../api/models-api";
import type { TrainingConfigState } from "../types/config";
import { drafterRequestFrom } from "./drafter-config";
const EXPECTED_TOP_KEYS = new Set(["training", "lora", "logging", "inference"]);
@ -102,6 +103,10 @@ export function serializeConfigToYaml(
optim: state.optimizerType,
lr_scheduler_type: state.lrSchedulerType,
};
// Only when on, so a file exported without the feature reads exactly as before.
if (state.drafterEnabled) {
training.drafter = drafterRequestFrom(state);
}
if (includeVisionImageSize) {
training.vision_image_size = state.visionImageSize;

View file

@ -19,6 +19,7 @@ import type {
TrainingConfigStore,
TrainingMethodProvenance,
} from "../types/config";
import { drafterStateFrom } from "../lib/drafter-config";
import {
createHfBrowseDatasetSelection,
createUploadBrowseDatasetSelection,
@ -26,7 +27,7 @@ import {
} from "./training-config-policy";
export const TRAINING_CONFIG_PERSISTENCE_NAME = "unsloth_training_config_v1";
export const TRAINING_CONFIG_PERSISTENCE_VERSION = 22;
export const TRAINING_CONFIG_PERSISTENCE_VERSION = 23;
const NON_PERSISTED_STATE_KEYS: ReadonlySet<keyof TrainingConfigState> =
new Set([
@ -45,6 +46,7 @@ const NON_PERSISTED_STATE_KEYS: ReadonlySet<keyof TrainingConfigState> =
"decisionLayout",
"s3Config",
"wandbToken",
"drafterCapability",
]);
export function partializeTrainingConfig(
@ -291,6 +293,27 @@ function migrateThroughVersion22(
}
}
// Drafter training arrived in 23: older state has no drafter keys, and a hand-edited or
// partially written one must never turn the phase on.
function migrateThroughVersion23(
state: PersistedTrainingConfig,
version: number,
): void {
if (version >= 23) return;
Object.assign(
state,
drafterStateFrom({
enabled: state.drafterEnabled,
strategy: state.drafterStrategy,
data_source: state.drafterDataSource,
max_examples: state.drafterMaxExamples,
epochs: state.drafterEpochs,
learning_rate: state.drafterLearningRate,
draft_steps: state.drafterDraftSteps,
}),
);
}
function isDatasetFormat(value: unknown): value is DatasetFormat {
return (
value === "auto" ||
@ -391,6 +414,7 @@ export function migrateTrainingConfig(
migrateThroughVersion19(state, version);
migrateThroughVersion21(state, version);
migrateThroughVersion22(state, version);
migrateThroughVersion23(state, version);
return state as unknown as TrainingConfigStore;
}

View file

@ -110,6 +110,7 @@ export const initialTrainingConfigState: TrainingConfigState = {
isDatasetAudio: false,
datasetCheckFailed: false,
maxPositionEmbeddings: null,
drafterCapability: null,
...DEFAULT_HYPERPARAMS,
};
export function hasSeparateStreamingEvalSplit(

View file

@ -22,6 +22,7 @@ import {
} from "../api/models-api";
import type { BackendModelConfig, DecisionCheckpoint } from "../api/models-api";
import { cacheReferenceMatchesSelection } from "../lib/cache-reference";
import { drafterStateFrom } from "../lib/drafter-config";
import {
createDatasetCacheUsabilityIdentity,
datasetCacheUsabilityIdentitiesEqual,
@ -1610,6 +1611,20 @@ export const useTrainingConfigStore = create<TrainingConfigStore>()(
},
setEarlyStoppingPatience: (earlyStoppingPatience) =>
setUserEdit({ earlyStoppingPatience }),
setDrafterEnabled: (drafterEnabled) => setUserEdit({ drafterEnabled }),
setDrafterStrategy: (drafterStrategy) =>
setUserEdit({ drafterStrategy }),
setDrafterDataSource: (drafterDataSource) =>
setUserEdit({ drafterDataSource }),
setDrafterMaxExamples: (drafterMaxExamples) =>
setUserEdit({ drafterMaxExamples }),
setDrafterEpochs: (drafterEpochs) => setUserEdit({ drafterEpochs }),
setDrafterLearningRate: (drafterLearningRate) =>
setUserEdit({ drafterLearningRate }),
setDrafterDraftSteps: (drafterDraftSteps) =>
setUserEdit({ drafterDraftSteps }),
setDrafterCapability: (drafterCapability) =>
set({ drafterCapability }),
setPacking: (packing) => setUserEdit({ packing }),
setTrainOnCompletions: (trainOnCompletions) => {
_trainOnCompletionsManuallySet = true;
@ -1671,6 +1686,7 @@ export const useTrainingConfigStore = create<TrainingConfigStore>()(
earlyStoppingPatience: earlyStoppingPatienceFrom(
config.early_stopping_patience,
),
...drafterStateFrom(config.drafter),
});
// Fetch capabilities without replacing the saved recipe.
loadAndApplyModelDefaults(selections.selectedModel, {
@ -1717,6 +1733,7 @@ export const useTrainingConfigStore = create<TrainingConfigStore>()(
config.training ?? {},
"early_stopping_patience",
);
const importsDrafter = Object.hasOwn(config.training ?? {}, "drafter");
setUserEdit((state) => {
const importsCompletionsInCpt =
state.trainingMethod === "cpt" &&
@ -1730,6 +1747,9 @@ export const useTrainingConfigStore = create<TrainingConfigStore>()(
),
}
: {}),
...(importsDrafter
? drafterStateFrom(config.training?.drafter)
: {}),
...(patch.trainOnCompletions !== undefined
? { trainOnCompletionsDefaultPendingFor: null }
: {}),

View file

@ -85,6 +85,31 @@ export interface TrainingStartRequest {
enable_tensorboard: boolean;
tensorboard_dir: string | null;
resume_from_checkpoint?: string | null;
/** Omitted unless the user turned drafter training on (requests stay byte-identical otherwise). */
drafter?: DrafterTrainingRequest;
}
export interface DrafterTrainingRequest {
enabled: boolean;
strategy: "auto" | "mtp" | "eagle3";
data_source: "dataset" | "regenerate";
max_examples: number;
epochs: number;
learning_rate: number | null;
draft_steps: number;
}
/** GET /api/train/drafter-capability: which drafter this model can train, and where it runs. */
export interface DrafterCapability {
model_name: string;
has_native_mtp: boolean;
strategy: "mtp" | "eagle3" | null;
supported: boolean;
reason: string | null;
vllm: boolean;
llama_cpp: boolean;
/** The probe could not run (timeout, crash): retried, never a refusal. */
inconclusive?: boolean;
}
export interface TrainingStartResponse {

View file

@ -15,6 +15,7 @@ import type {
DecisionCheckpoint,
DecisionLayout,
} from "../api/models-api";
import type { DrafterCapability } from "./api";
export type LoraVariant = "lora" | "rslora" | "loftq" | "dora";
@ -63,6 +64,11 @@ export interface TrainingMethodProvenance {
/** Column-to-role mapping, e.g. { "problem": "user", "solution": "assistant", "context": "system" } */
export type DatasetManualMapping = Record<string, string>;
/** "auto" retrains a native MTP head when the model has one, else trains an EAGLE-3 drafter. */
export type DrafterStrategy = "auto" | "mtp" | "eagle3";
/** "dataset" trains on the dataset's answers; "regenerate" on the fine-tuned model's own answers. */
export type DrafterDataSource = "dataset" | "regenerate";
/** Decoder layers kept in host RAM during LoRA training: a count (0 = off) or "auto". */
export type OffloadLayers = number | "auto";
export type PrefetchDepth = number | "auto";
@ -125,6 +131,17 @@ export interface TrainingConfigState {
evalSteps: number;
/** Evaluations without a lower eval loss before the run stops; 0 = off. */
earlyStoppingPatience: number;
/** Train a speculative-decoding drafter (MTP head or EAGLE-3) after fine-tuning. */
drafterEnabled: boolean;
drafterStrategy: DrafterStrategy;
drafterDataSource: DrafterDataSource;
drafterMaxExamples: number;
drafterEpochs: number;
/** null = the backend's default for the resolved strategy. */
drafterLearningRate: number | null;
drafterDraftSteps: number;
/** Last /api/train/drafter-capability answer (its `model_name` says which model it is for). */
drafterCapability: DrafterCapability | null;
packing: boolean;
trainOnCompletions: boolean;
gradientCheckpointing: GradientCheckpointing;
@ -267,6 +284,14 @@ export interface TrainingConfigActions {
setSaveSteps: (value: number) => void;
setEvalSteps: (value: number) => void;
setEarlyStoppingPatience: (value: number) => void;
setDrafterEnabled: (value: boolean) => void;
setDrafterStrategy: (value: DrafterStrategy) => void;
setDrafterDataSource: (value: DrafterDataSource) => void;
setDrafterMaxExamples: (value: number) => void;
setDrafterEpochs: (value: number) => void;
setDrafterLearningRate: (value: number | null) => void;
setDrafterDraftSteps: (value: number) => void;
setDrafterCapability: (value: DrafterCapability | null) => void;
setPacking: (value: boolean) => void;
setTrainOnCompletions: (value: boolean) => void;
setGradientCheckpointing: (value: GradientCheckpointing) => void;

View file

@ -11,6 +11,8 @@ export type TrainingPhase =
| "training"
// Steps done, worker still saving. Non-terminal: 100% is not success.
| "finalizing"
// SFT saved; training the speculative-decoding drafter. Non-terminal.
| "training_drafter"
| "completed"
| "error"
| "stopped";

View file

@ -3609,6 +3609,21 @@ export const ar = {
"يُستخدم في أسماء مجلدات مخرجات التدريب والقيم الافتراضية للتصدير والسجل.",
loraSettings: "إعدادات LoRA",
trainingHyperparameters: "المعلمات الفائقة للتدريب",
drafterSettings: "مسودّة فك الترميز التخميني",
drafterTrain: "تدريب مسودّة فك الترميز التخميني",
drafterTrainTooltip: "يدرّب رأس مسودة صغيرًا بعد الضبط الدقيق لتسريع الاستدلال في llama.cpp وvLLM؛ المخرجات لا تتغير.",
drafterChecking: "جارٍ فحص النموذج...",
drafterUnavailable: "غير متاح لهذا النموذج: {reason}",
drafterRetrainMtp: "يعيد تدريب رأس MTP لهذا النموذج",
drafterEagle3: "يدرّب مسودّة EAGLE-3 (لا يملك هذا النموذج رأس MTP)",
drafterDataSource: "بيانات التدريب",
drafterDataSourceDataset: "إجابات مجموعة البيانات",
drafterDataSourceRegenerate: "إجابات النموذج نفسه (أبطأ، أفضل)",
drafterMaxExamples: "الأمثلة",
drafterEpochs: "الحقب",
drafterLearningRate: "معدل التعلم",
drafterLearningRateDefault: "افتراضي",
drafterDraftSteps: "رموز المسودة",
maxSteps: "الحد الأقصى للخطوات",
epochs: "الحقب (Epochs)",
useMaxSteps: "استخدام الحد الأقصى للخطوات",
@ -3954,6 +3969,7 @@ export const ar = {
configuring: "جارٍ الإعداد",
training: "قيد التدريب",
finalizing: "جارٍ حفظ النموذج",
trainingDrafter: "جارٍ تدريب المسودّة",
completed: "مكتمل",
error: "خطأ",
stopped: "متوقف",

View file

@ -3664,6 +3664,21 @@ export const de = {
"Wird für Namen der Trainingsausgabe-Ordner, Export-Standardwerte und den Verlauf verwendet.",
loraSettings: "LoRA-Einstellungen",
trainingHyperparameters: "Trainings-Hyperparameter",
drafterSettings: "Speculative-Decoding-Drafter",
drafterTrain: "Speculative-Decoding-Drafter trainieren",
drafterTrainTooltip: "Trainiert nach dem Fine-Tuning einen kleinen Draft-Kopf, damit die Inferenz in llama.cpp und vLLM schneller läuft; die Ausgaben bleiben gleich.",
drafterChecking: "Modell wird geprüft...",
drafterUnavailable: "Für dieses Modell nicht verfügbar: {reason}",
drafterRetrainMtp: "Trainiert den MTP-Kopf dieses Modells neu",
drafterEagle3: "Trainiert einen EAGLE-3-Drafter (dieses Modell hat keinen MTP-Kopf)",
drafterDataSource: "Trainingsdaten",
drafterDataSourceDataset: "Antworten aus dem Datensatz",
drafterDataSourceRegenerate: "Eigene Antworten des Modells (langsamer, besser)",
drafterMaxExamples: "Beispiele",
drafterEpochs: "Epochen",
drafterLearningRate: "Lernrate",
drafterLearningRateDefault: "Standard",
drafterDraftSteps: "Draft-Tokens",
maxSteps: "Max. Schritte",
epochs: "Epochen",
useMaxSteps: "Max. Schritte verwenden",
@ -4027,6 +4042,7 @@ export const de = {
configuring: "Wird konfiguriert",
training: "Training",
finalizing: "Modell wird gespeichert",
trainingDrafter: "Drafter wird trainiert",
completed: "Abgeschlossen",
error: "Fehler",
stopped: "Gestoppt",

View file

@ -3648,6 +3648,21 @@ export const en = {
"Used in training output folder names, export defaults, and history.",
loraSettings: "LoRA Settings",
trainingHyperparameters: "Training Hyperparameters",
drafterSettings: "Speculative Decoding Drafter",
drafterTrain: "Train a speculative decoding drafter",
drafterTrainTooltip: "Trains a small draft head after fine-tuning so inference runs faster in llama.cpp and vLLM; outputs are unchanged.",
drafterChecking: "Checking this model...",
drafterUnavailable: "Not available for this model: {reason}",
drafterRetrainMtp: "Retrains this model's MTP head",
drafterEagle3: "Trains an EAGLE-3 drafter (this model has no MTP head)",
drafterDataSource: "Training data",
drafterDataSourceDataset: "Dataset answers",
drafterDataSourceRegenerate: "Model's own answers (slower, better)",
drafterMaxExamples: "Examples",
drafterEpochs: "Epochs",
drafterLearningRate: "Learning rate",
drafterLearningRateDefault: "Default",
drafterDraftSteps: "Draft tokens",
maxSteps: "Max Steps",
epochs: "Epochs",
useMaxSteps: "Use Max Steps",
@ -3993,6 +4008,7 @@ export const en = {
configuring: "Configuring",
training: "Training",
finalizing: "Saving model",
trainingDrafter: "Training drafter",
completed: "Completed",
error: "Error",
stopped: "Stopped",

View file

@ -3660,6 +3660,21 @@ export const es = {
"Se usa en los nombres de las carpetas de salida del entrenamiento, los valores predeterminados de exportación y el historial.",
loraSettings: "Configuración de LoRA",
trainingHyperparameters: "Hiperparámetros de entrenamiento",
drafterSettings: "Drafter de decodificación especulativa",
drafterTrain: "Entrenar un drafter de decodificación especulativa",
drafterTrainTooltip: "Entrena una pequeña cabeza de borrador tras el ajuste fino para que la inferencia sea más rápida en llama.cpp y vLLM; las salidas no cambian.",
drafterChecking: "Comprobando este modelo...",
drafterUnavailable: "No disponible para este modelo: {reason}",
drafterRetrainMtp: "Reentrena la cabeza MTP de este modelo",
drafterEagle3: "Entrena un drafter EAGLE-3 (este modelo no tiene cabeza MTP)",
drafterDataSource: "Datos de entrenamiento",
drafterDataSourceDataset: "Respuestas del dataset",
drafterDataSourceRegenerate: "Respuestas del propio modelo (más lento, mejor)",
drafterMaxExamples: "Ejemplos",
drafterEpochs: "Épocas",
drafterLearningRate: "Tasa de aprendizaje",
drafterLearningRateDefault: "Predeterminado",
drafterDraftSteps: "Tokens de borrador",
maxSteps: "Pasos máximos",
epochs: "Épocas",
useMaxSteps: "Usar pasos máximos",
@ -4024,6 +4039,7 @@ export const es = {
configuring: "Configurando",
training: "Entrenando",
finalizing: "Guardando modelo",
trainingDrafter: "Entrenando drafter",
completed: "Completado",
error: "Error",
stopped: "Detenido",

View file

@ -3673,6 +3673,21 @@ export const fr = {
"Utilisé dans les noms des dossiers de sortie d'entraînement, les valeurs d'export par défaut et l'historique.",
loraSettings: "Paramètres LoRA",
trainingHyperparameters: "Hyperparamètres d'entraînement",
drafterSettings: "Drafter de décodage spéculatif",
drafterTrain: "Entraîner un drafter de décodage spéculatif",
drafterTrainTooltip: "Entraîne une petite tête de brouillon après le fine-tuning pour accélérer l'inférence dans llama.cpp et vLLM ; les sorties sont inchangées.",
drafterChecking: "Vérification du modèle...",
drafterUnavailable: "Indisponible pour ce modèle : {reason}",
drafterRetrainMtp: "Réentraîne la tête MTP de ce modèle",
drafterEagle3: "Entraîne un drafter EAGLE-3 (ce modèle n'a pas de tête MTP)",
drafterDataSource: "Données d'entraînement",
drafterDataSourceDataset: "Réponses du dataset",
drafterDataSourceRegenerate: "Réponses du modèle lui-même (plus lent, meilleur)",
drafterMaxExamples: "Exemples",
drafterEpochs: "Époques",
drafterLearningRate: "Taux d'apprentissage",
drafterLearningRateDefault: "Par défaut",
drafterDraftSteps: "Tokens de brouillon",
maxSteps: "Nombre max d'étapes",
epochs: "Époques",
useMaxSteps: "Utiliser le nombre max d'étapes",
@ -4038,6 +4053,7 @@ export const fr = {
configuring: "Configuration",
training: "Entraînement",
finalizing: "Enregistrement du modèle",
trainingDrafter: "Entraînement du drafter",
completed: "Terminé",
error: "Erreur",
stopped: "Arrêté",

View file

@ -3717,6 +3717,21 @@ export const he = {
"משמש בשמות תיקיות פלט האימון, ברירות מחדל של ייצוא והיסטוריה.",
loraSettings: "הגדרות LoRA",
trainingHyperparameters: "היפר-פרמטרים של אימון",
drafterSettings: "מנסח לפענוח ספקולטיבי",
drafterTrain: "אימון מנסח לפענוח ספקולטיבי",
drafterTrainTooltip: "מאמן ראש טיוטה קטן אחרי הכוונון כדי שההסקה ב-llama.cpp וב-vLLM תהיה מהירה יותר; הפלט לא משתנה.",
drafterChecking: "בודק את המודל...",
drafterUnavailable: "לא זמין למודל הזה: {reason}",
drafterRetrainMtp: "מאמן מחדש את ראש ה-MTP של המודל",
drafterEagle3: "מאמן מנסח EAGLE-3 (למודל אין ראש MTP)",
drafterDataSource: "נתוני אימון",
drafterDataSourceDataset: "תשובות ממערך הנתונים",
drafterDataSourceRegenerate: "התשובות של המודל עצמו (איטי יותר, טוב יותר)",
drafterMaxExamples: "דוגמאות",
drafterEpochs: "אפוקים",
drafterLearningRate: "קצב למידה",
drafterLearningRateDefault: "ברירת מחדל",
drafterDraftSteps: "טוקני טיוטה",
maxSteps: "מקסימום צעדים (Max Steps)",
epochs: "תקופות (Epochs)",
useMaxSteps: "השתמש ב-Max Steps",
@ -4059,6 +4074,7 @@ export const he = {
configuring: "מגדיר",
training: "מאמן",
finalizing: "שומר מודל",
trainingDrafter: "מאמן מנסח",
completed: "הושלם",
error: "שגיאה",
stopped: "נעצר",

View file

@ -3619,6 +3619,21 @@ export const hi = {
"ट्रेनिंग आउटपुट फ़ोल्डर नामों, एक्सपोर्ट डिफ़ॉल्ट, और इतिहास में उपयोग किया जाता है।",
loraSettings: "LoRA सेटिंग्स",
trainingHyperparameters: "ट्रेनिंग हाइपरपैरामीटर",
drafterSettings: "स्पेक्युलेटिव डिकोडिंग ड्राफ्टर",
drafterTrain: "स्पेक्युलेटिव डिकोडिंग ड्राफ्टर ट्रेन करें",
drafterTrainTooltip: "फाइन-ट्यूनिंग के बाद एक छोटा ड्राफ्ट हेड ट्रेन करता है ताकि llama.cpp और vLLM में इनफ़रेंस तेज़ चले; आउटपुट नहीं बदलते।",
drafterChecking: "मॉडल जाँचा जा रहा है...",
drafterUnavailable: "इस मॉडल के लिए उपलब्ध नहीं: {reason}",
drafterRetrainMtp: "इस मॉडल का MTP हेड दोबारा ट्रेन करता है",
drafterEagle3: "EAGLE-3 ड्राफ्टर ट्रेन करता है (इस मॉडल में MTP हेड नहीं है)",
drafterDataSource: "ट्रेनिंग डेटा",
drafterDataSourceDataset: "डेटासेट के उत्तर",
drafterDataSourceRegenerate: "मॉडल के अपने उत्तर (धीमा, बेहतर)",
drafterMaxExamples: "उदाहरण",
drafterEpochs: "एपॉक",
drafterLearningRate: "लर्निंग रेट",
drafterLearningRateDefault: "डिफ़ॉल्ट",
drafterDraftSteps: "ड्राफ्ट टोकन",
maxSteps: "अधिकतम स्टेप्स",
epochs: "एपॉक",
useMaxSteps: "अधिकतम स्टेप्स का उपयोग करें",
@ -3965,6 +3980,7 @@ export const hi = {
configuring: "कॉन्फ़िगर हो रहा है",
training: "ट्रेनिंग हो रही है",
finalizing: "मॉडल सेव हो रहा है",
trainingDrafter: "ड्राफ्टर ट्रेन हो रहा है",
completed: "पूर्ण",
error: "त्रुटि",
stopped: "रोका गया",

View file

@ -3670,6 +3670,21 @@ export const it = {
"Usato nei nomi delle cartelle di output, nei valori predefiniti di esportazione e nella cronologia.",
loraSettings: "Impostazioni LoRA",
trainingHyperparameters: "Iperparametri di addestramento",
drafterSettings: "Drafter per decodifica speculativa",
drafterTrain: "Addestra un drafter per decodifica speculativa",
drafterTrainTooltip: "Addestra una piccola testa di bozza dopo il fine-tuning così l'inferenza è più veloce in llama.cpp e vLLM; gli output non cambiano.",
drafterChecking: "Verifica del modello...",
drafterUnavailable: "Non disponibile per questo modello: {reason}",
drafterRetrainMtp: "Riaddestra la testa MTP di questo modello",
drafterEagle3: "Addestra un drafter EAGLE-3 (questo modello non ha una testa MTP)",
drafterDataSource: "Dati di addestramento",
drafterDataSourceDataset: "Risposte del dataset",
drafterDataSourceRegenerate: "Risposte del modello stesso (più lento, migliore)",
drafterMaxExamples: "Esempi",
drafterEpochs: "Epoche",
drafterLearningRate: "Learning rate",
drafterLearningRateDefault: "Predefinito",
drafterDraftSteps: "Token di bozza",
maxSteps: "Step massimi",
epochs: "Epoche",
useMaxSteps: "Usa gli step massimi",
@ -4029,6 +4044,7 @@ export const it = {
configuring: "Configurazione",
training: "Addestramento",
finalizing: "Salvataggio del modello",
trainingDrafter: "Addestramento del drafter",
completed: "Completato",
error: "Errore",
stopped: "Fermato",

View file

@ -3570,6 +3570,21 @@ export const ja = {
projectNameDescription: "トレーニング出力フォルダ名、エクスポートのデフォルト値、および履歴で使用されます。",
loraSettings: "LoRA 設定",
trainingHyperparameters: "トレーニングハイパーパラメーター",
drafterSettings: "投機的デコーディング用ドラフター",
drafterTrain: "投機的デコーディング用ドラフターをトレーニング",
drafterTrainTooltip: "ファインチューニング後に小さなドラフトヘッドをトレーニングし、llama.cpp と vLLM での推論を高速化します。出力は変わりません。",
drafterChecking: "モデルを確認中...",
drafterUnavailable: "このモデルでは利用できません: {reason}",
drafterRetrainMtp: "このモデルの MTP ヘッドを再トレーニングします",
drafterEagle3: "EAGLE-3 ドラフターをトレーニングします (このモデルには MTP ヘッドがありません)",
drafterDataSource: "トレーニングデータ",
drafterDataSourceDataset: "データセットの回答",
drafterDataSourceRegenerate: "モデル自身の回答 (遅いがより良い)",
drafterMaxExamples: "サンプル数",
drafterEpochs: "エポック",
drafterLearningRate: "学習率",
drafterLearningRateDefault: "デフォルト",
drafterDraftSteps: "ドラフトトークン数",
maxSteps: "最大ステップ数",
epochs: "エポック数",
useMaxSteps: "最大ステップ数を使用",
@ -3901,6 +3916,7 @@ export const ja = {
configuring: "構成中",
training: "トレーニング中",
finalizing: "モデルを保存中",
trainingDrafter: "ドラフターをトレーニング中",
completed: "完了",
error: "エラー",
stopped: "停止",

View file

@ -3597,6 +3597,21 @@ export const ko = {
"학습 출력 폴더 이름, 내보내기 기본값 및 기록에 사용됩니다.",
loraSettings: "LoRA 설정",
trainingHyperparameters: "학습 하이퍼파라미터",
drafterSettings: "추측 디코딩 드래프터",
drafterTrain: "추측 디코딩 드래프터 학습",
drafterTrainTooltip: "파인튜닝 후 작은 드래프트 헤드를 학습해 llama.cpp와 vLLM에서 추론을 더 빠르게 합니다. 출력은 바뀌지 않습니다.",
drafterChecking: "모델 확인 중...",
drafterUnavailable: "이 모델에서는 사용할 수 없음: {reason}",
drafterRetrainMtp: "이 모델의 MTP 헤드를 다시 학습합니다",
drafterEagle3: "EAGLE-3 드래프터를 학습합니다 (이 모델에는 MTP 헤드가 없음)",
drafterDataSource: "학습 데이터",
drafterDataSourceDataset: "데이터셋 답변",
drafterDataSourceRegenerate: "모델 자체 답변 (느리지만 더 좋음)",
drafterMaxExamples: "예제 수",
drafterEpochs: "에포크",
drafterLearningRate: "학습률",
drafterLearningRateDefault: "기본값",
drafterDraftSteps: "드래프트 토큰",
maxSteps: "최대 스텝",
epochs: "에포크",
useMaxSteps: "최대 스텝 사용",
@ -3943,6 +3958,7 @@ export const ko = {
configuring: "구성 중",
training: "학습 중",
finalizing: "모델 저장 중",
trainingDrafter: "드래프터 학습 중",
completed: "완료됨",
error: "오류",
stopped: "중지됨",

View file

@ -3635,6 +3635,21 @@ export const ptBR = {
"Usado nos nomes das pastas de saída do treinamento, nos padrões de exportação e no histórico.",
loraSettings: "Configurações do LoRA",
trainingHyperparameters: "Hiperparâmetros de Treinamento",
drafterSettings: "Drafter de decodificação especulativa",
drafterTrain: "Treinar um drafter de decodificação especulativa",
drafterTrainTooltip: "Treina uma pequena cabeça de rascunho após o fine-tuning para a inferência ficar mais rápida no llama.cpp e no vLLM; as saídas não mudam.",
drafterChecking: "Verificando este modelo...",
drafterUnavailable: "Indisponível para este modelo: {reason}",
drafterRetrainMtp: "Retreina a cabeça MTP deste modelo",
drafterEagle3: "Treina um drafter EAGLE-3 (este modelo não tem cabeça MTP)",
drafterDataSource: "Dados de treinamento",
drafterDataSourceDataset: "Respostas do dataset",
drafterDataSourceRegenerate: "Respostas do próprio modelo (mais lento, melhor)",
drafterMaxExamples: "Exemplos",
drafterEpochs: "Épocas",
drafterLearningRate: "Taxa de aprendizado",
drafterLearningRateDefault: "Padrão",
drafterDraftSteps: "Tokens de rascunho",
maxSteps: "Passos Máximos (Max Steps)",
epochs: "Épocas (Epochs)",
useMaxSteps: "Usar Passos Máximos",
@ -3987,6 +4002,7 @@ export const ptBR = {
configuring: "Configurando",
training: "Treinando",
finalizing: "Salvando modelo",
trainingDrafter: "Treinando drafter",
completed: "Concluído",
error: "Erro",
stopped: "Interrompido",

View file

@ -3635,6 +3635,21 @@ export const ru = {
"Используется в именах папок вывода обучения, значениях экспорта по умолчанию и истории.",
loraSettings: "Настройки LoRA",
trainingHyperparameters: "Гиперпараметры обучения",
drafterSettings: "Драфтер для спекулятивного декодирования",
drafterTrain: "Обучить драфтер для спекулятивного декодирования",
drafterTrainTooltip: "После дообучения обучает небольшую черновую голову, чтобы инференс в llama.cpp и vLLM работал быстрее; ответы не меняются.",
drafterChecking: "Проверка модели...",
drafterUnavailable: "Недоступно для этой модели: {reason}",
drafterRetrainMtp: "Переобучает MTP-голову этой модели",
drafterEagle3: "Обучает драфтер EAGLE-3 (у этой модели нет MTP-головы)",
drafterDataSource: "Данные для обучения",
drafterDataSourceDataset: "Ответы из датасета",
drafterDataSourceRegenerate: "Собственные ответы модели (медленнее, лучше)",
drafterMaxExamples: "Примеры",
drafterEpochs: "Эпохи",
drafterLearningRate: "Скорость обучения",
drafterLearningRateDefault: "По умолчанию",
drafterDraftSteps: "Черновые токены",
maxSteps: "Макс. шагов",
epochs: "Эпохи",
useMaxSteps: "Использовать макс. шаги",
@ -3984,6 +3999,7 @@ export const ru = {
configuring: "Настройка",
training: "Обучение",
finalizing: "Сохранение модели",
trainingDrafter: "Обучение драфтера",
completed: "Завершено",
error: "Ошибка",
stopped: "Остановлено",

View file

@ -3839,6 +3839,21 @@ export const sv = {
"Används i namn på träningsutdatakataloger, exportstandardvärden och historik.",
loraSettings: "LoRA-inställningar",
trainingHyperparameters: "Träningshyperparametrar",
drafterSettings: "Drafter för spekulativ avkodning",
drafterTrain: "Träna en drafter för spekulativ avkodning",
drafterTrainTooltip: "Tränar ett litet utkasthuvud efter finjusteringen så att inferensen går snabbare i llama.cpp och vLLM; utdata ändras inte.",
drafterChecking: "Kontrollerar modellen...",
drafterUnavailable: "Inte tillgängligt för den här modellen: {reason}",
drafterRetrainMtp: "Tränar om modellens MTP-huvud",
drafterEagle3: "Tränar en EAGLE-3-drafter (modellen saknar MTP-huvud)",
drafterDataSource: "Träningsdata",
drafterDataSourceDataset: "Svar från datasetet",
drafterDataSourceRegenerate: "Modellens egna svar (långsammare, bättre)",
drafterMaxExamples: "Exempel",
drafterEpochs: "Epoker",
drafterLearningRate: "Inlärningstakt",
drafterLearningRateDefault: "Standard",
drafterDraftSteps: "Utkasttokens",
maxSteps: "Maxsteg",
epochs: "Epoker",
useMaxSteps: "Använd maxsteg",
@ -4191,6 +4206,7 @@ export const sv = {
configuring: "Konfigurerar",
training: "Tränar",
finalizing: "Sparar modell",
trainingDrafter: "Tränar drafter",
completed: "Slutförd",
error: "Fel",
stopped: "Stoppad",

View file

@ -3529,6 +3529,21 @@ export const zhCN = {
projectNameDescription: "用于训练输出文件夹名称、导出默认值和历史记录。",
loraSettings: "LoRA 设置",
trainingHyperparameters: "训练超参数",
drafterSettings: "推测解码草稿模型",
drafterTrain: "训练推测解码草稿模型",
drafterTrainTooltip: "微调后训练一个小型草稿头,使 llama.cpp 和 vLLM 中的推理更快;输出保持不变。",
drafterChecking: "正在检查模型...",
drafterUnavailable: "此模型不可用:{reason}",
drafterRetrainMtp: "重新训练此模型的 MTP 头",
drafterEagle3: "训练 EAGLE-3 草稿模型(此模型没有 MTP 头)",
drafterDataSource: "训练数据",
drafterDataSourceDataset: "数据集答案",
drafterDataSourceRegenerate: "模型自身的回答(更慢,效果更好)",
drafterMaxExamples: "样本数",
drafterEpochs: "轮数",
drafterLearningRate: "学习率",
drafterLearningRateDefault: "默认",
drafterDraftSteps: "草稿 token 数",
maxSteps: "最大步数",
epochs: "轮数",
useMaxSteps: "使用最大步数",
@ -3857,6 +3872,7 @@ export const zhCN = {
configuring: "配置中",
training: "训练中",
finalizing: "正在保存模型",
trainingDrafter: "正在训练草稿模型",
completed: "已完成",
error: "错误",
stopped: "已停止",

View file

@ -68,7 +68,7 @@ test("migration preserves tuned values while protecting them from model defaults
16,
);
assert.equal(TRAINING_CONFIG_PERSISTENCE_VERSION, 22);
assert.equal(TRAINING_CONFIG_PERSISTENCE_VERSION, 23);
assert.equal(migrated.learningRate, 0.000031);
assert.equal(migrated.loraRank, 48);
assert.equal(migrated.modelDefaultsAppliedFor, "org/model");

View file

@ -0,0 +1,209 @@
// 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 assert from "node:assert/strict";
import test from "node:test";
import { registerBundlerResolver } from "./helpers/kit.ts";
import type { DrafterCapability } from "../src/features/training/types/api.ts";
import type { TrainingConfigState } from "../src/features/training/types/config.ts";
registerBundlerResolver();
const { buildTrainingStartPayload } =
await import("../src/features/training/api/mappers.ts");
const { initialTrainingConfigState } =
await import("../src/features/training/stores/training-config-policy.ts");
const {
TRAINING_CONFIG_PERSISTENCE_VERSION,
migrateTrainingConfig,
partializeTrainingConfig,
} =
await import("../src/features/training/stores/training-config-persistence.ts");
const { parseYamlConfig, serializeConfigToYaml } =
await import("../src/features/training/lib/yaml-config.ts");
const { drafterStateFrom } =
await import("../src/features/training/lib/drafter-config.ts");
const { phaseColors } =
await import("../src/features/studio/sections/progress-section-lib.ts");
const MODEL = "unsloth/Qwen3-1.7B";
function capability(patch: Partial<DrafterCapability> = {}): DrafterCapability {
return {
model_name: MODEL,
has_native_mtp: false,
strategy: "eagle3",
supported: true,
reason: null,
vllm: true,
llama_cpp: true,
...patch,
};
}
function config(patch: Partial<TrainingConfigState> = {}): TrainingConfigState {
return {
...initialTrainingConfigState,
modelType: "text",
selectedModel: MODEL,
datasetSource: "huggingface",
dataset: "unsloth/test",
...patch,
};
}
const ON = {
drafterEnabled: true,
drafterStrategy: "auto",
drafterDataSource: "regenerate",
drafterMaxExamples: 1500,
drafterEpochs: 2,
drafterLearningRate: 0.0002,
drafterDraftSteps: 4,
} as const;
test("drafter training is off by default and the payload has no drafter key", () => {
assert.equal(initialTrainingConfigState.drafterEnabled, false);
const payload = buildTrainingStartPayload(config(), null);
assert.equal(Object.hasOwn(payload, "drafter"), false);
});
test("a capability check still pending or for another model does not drop a ticked drafter", () => {
for (const patch of [
{ ...ON },
{ ...ON, drafterCapability: capability({ model_name: "other/model" }) },
// The probe could not run: the backend re-checks before the drafter phase.
{
...ON,
drafterCapability: capability({ supported: false, inconclusive: true }),
},
]) {
const payload = buildTrainingStartPayload(config(patch), null);
assert.equal(Object.hasOwn(payload, "drafter"), true);
}
});
test("settings the request cannot carry leave the payload exactly as without the feature", () => {
const off = buildTrainingStartPayload(config(), null);
const cases: Partial<TrainingConfigState>[] = [
// Settings edited but the box unticked.
{ ...ON, drafterEnabled: false, drafterCapability: capability() },
// Ticked, but the backend said this model cannot train one.
{ ...ON, drafterCapability: capability({ supported: false, reason: "x" }) },
// Run types without a drafter phase.
{ ...ON, drafterCapability: capability(), trainingMethod: "cpt" },
{ ...ON, drafterCapability: capability(), isEmbeddingModel: true },
];
for (const patch of cases) {
const payload = buildTrainingStartPayload(config(patch), null);
if (patch.trainingMethod === "cpt" || patch.isEmbeddingModel) {
assert.equal(Object.hasOwn(payload, "drafter"), false);
} else {
assert.deepEqual(payload, off);
}
}
const mlx = buildTrainingStartPayload(
config({ ...ON, drafterCapability: capability() }),
null,
{ device_backend: "mlx" } as never,
);
assert.equal(Object.hasOwn(mlx, "drafter"), false);
// Image / audio fine-tunes have no drafter phase; the same models on a text dataset do.
for (const patch of [
{ isVisionModel: true, isDatasetImage: true },
{ isAudioModel: true, isDatasetAudio: true },
]) {
const media = buildTrainingStartPayload(
config({ ...ON, drafterCapability: capability(), ...patch }),
null,
);
assert.equal(Object.hasOwn(media, "drafter"), false);
}
const text = buildTrainingStartPayload(
config({
...ON,
drafterCapability: capability(),
isVisionModel: true,
isDatasetImage: false,
}),
null,
);
assert.equal(Object.hasOwn(text, "drafter"), true);
});
test("an enabled drafter on a supported model adds only the drafter block", () => {
const off = buildTrainingStartPayload(config(), null);
const on = buildTrainingStartPayload(
config({ ...ON, drafterCapability: capability() }),
null,
);
const { drafter, ...rest } = on;
assert.deepEqual(rest, off);
assert.deepEqual(drafter, {
enabled: true,
strategy: "auto",
data_source: "regenerate",
max_examples: 1500,
epochs: 2,
learning_rate: 0.0002,
draft_steps: 4,
});
});
test("half-typed fields are clamped into the backend's bounds", () => {
const sent = (patch: Partial<TrainingConfigState>) =>
buildTrainingStartPayload(
config({ ...ON, drafterCapability: capability(), ...patch }),
null,
).drafter;
assert.equal(sent({ drafterMaxExamples: 3 })?.max_examples, 16);
assert.equal(sent({ drafterMaxExamples: 1e9 })?.max_examples, 100000);
assert.equal(sent({ drafterEpochs: 0 })?.epochs, 0.1);
assert.equal(sent({ drafterDraftSteps: 2.7 })?.draft_steps, 2);
assert.equal(sent({ drafterDraftSteps: Number.NaN })?.draft_steps, 3);
assert.equal(sent({ drafterLearningRate: -1 })?.learning_rate, null);
});
test("persisted state from before drafter training migrates to the feature off", () => {
assert.equal(TRAINING_CONFIG_PERSISTENCE_VERSION, 23);
const migrated = migrateTrainingConfig(
{ selectedModel: MODEL, trainingMethod: "lora" },
22,
) as unknown as TrainingConfigState;
assert.equal(migrated.drafterEnabled, false);
assert.equal(migrated.drafterStrategy, "auto");
assert.equal(migrated.drafterMaxExamples, 2000);
assert.equal(migrated.drafterLearningRate, null);
const malformed = migrateTrainingConfig(
{ drafterEnabled: "yes", drafterStrategy: "medusa", drafterDraftSteps: 99 },
22,
) as unknown as TrainingConfigState;
assert.equal(malformed.drafterEnabled, false);
assert.equal(malformed.drafterStrategy, "auto");
assert.equal(malformed.drafterDraftSteps, 8);
});
test("the capability answer is never persisted, the settings are", () => {
const persisted = partializeTrainingConfig({
...config({ ...ON, drafterCapability: capability() }),
} as never);
assert.equal(Object.hasOwn(persisted, "drafterCapability"), false);
assert.equal(persisted.drafterEnabled, true);
assert.equal(persisted.drafterDataSource, "regenerate");
});
test("YAML round trip keeps drafter settings, and leaves files without it unchanged", () => {
const offYaml = serializeConfigToYaml(config(), false);
assert.equal(
Object.hasOwn(parseYamlConfig(offYaml).training ?? {}, "drafter"),
false,
);
const onYaml = serializeConfigToYaml(config({ ...ON }), false);
const restored = drafterStateFrom(parseYamlConfig(onYaml).training?.drafter);
assert.deepEqual(restored, { ...ON });
});
test("the drafter phase has a progress colour (labels are typed exhaustively)", () => {
assert.ok(phaseColors.training_drafter);
});

View file

@ -248,6 +248,15 @@ def __getattr__(name):
_zoo_speculative()
from unsloth_zoo.speculative import trainer
return getattr(trainer, name)
if name in ("UnslothEagle3Trainer", "Eagle3TrainingArguments"):
speculative = _zoo_speculative()
try:
return getattr(speculative, name)
except AttributeError as error:
raise ImportError(
"Unsloth: EAGLE-3 drafter training needs a newer unsloth_zoo. "
"Update via `pip install --upgrade --no-cache-dir unsloth_zoo`."
) from error
if name in ("UnslothDSparkTrainer", "DSparkTrainingArguments", "Qwen3DSparkDrafter"):
_zoo_speculative()
from unsloth_zoo.speculative import dspark