Local training
Use trl() when you want open-weight training on hardware you control. Use a managed trainer when you'd rather the provider own compute and serving.
trl() fine-tunes open-weight models on your own machine with Hugging Face TRL and PEFT. June orchestrates TRL; it doesn't embed an ML framework. The training runs in a separate Python process, and none of its dependencies are npm dependencies.
Prerequisites
- uv. June runs its runner with
uv run --script, which reads the runner's inline dependencies and installs them in an isolated, cached environment. You don't create a virtualenv or install anything by hand. - A machine that can hold the model. See Hardware.
- For gated models, a Hugging Face token:
trl({ env: { HF_TOKEN } }).
If uv isn't on PATH, fineTune() rejects before starting, with instructions.
To use a Python environment you manage instead, pass the command that runs a script in it. The environment needs the packages listed below:
import { trl } from "june/trainers";
// An absolute path, such as path.resolve(".venv/bin/python"): the runner starts in the run directory
const trainer = trl({ python: ["/home/me/project/.venv/bin/python"] });
Hardware
Decide whether local training is realistic before you install anything. Real training wants a GPU:
| Machine | What's realistic |
|---|---|
| CPU or Apple Silicon | models under about 1B parameters, slowly: enough to try the pipeline end to end |
| a 24 GB NVIDIA GPU | LoRA on an 8B model in bf16, roughly |
| a smaller NVIDIA GPU, around 10 GB | QLoRA on an 8B model, at some cost in speed; QLoRA needs CUDA |
| several large GPUs | full sft, which needs several times the model's size in GPU memory |
These are rough guides, not promises: memory use depends on sequence length, batch size and the model. If none of these is your machine, use managed training instead.
Normal tests and CI never train a model or need a GPU; see Testing.
A first run
import { dataset, fineTune } from "june/train";
import { trl } from "june/trainers";
const result = await fineTune({
name: "fonzie",
model: "Qwen/Qwen3-8B",
trainer: trl(),
dataset: dataset("./fonzie.jsonl"),
method: { type: "lora", rank: 32, alpha: 64 },
config: { epochs: 2, learningRate: 2e-4 },
});
result.artifact;
// { type: "adapter", format: "peft", uri: "/…/.june/trl/fonzie-trl-3f1a9c2e/output", baseModel: "Qwen/Qwen3-8B" }
Methods and artifacts
| Method | What it trains | Artifact |
|---|---|---|
sft | every weight of the model | checkpoint in transformers format |
lora | a LoRA adapter on top of the frozen base model | adapter in peft format |
qlora | a LoRA adapter on a 4-bit quantized base model | adapter in peft format |
LoRA options come from the method config: rank (16 by default), alpha (32), dropout (0.05) and targetModules (all linear layers). config sets epochs, learningRate, batchSize, maxSequenceLength and seed; TRL's defaults apply to anything unset.
Datasets must be all completion or all conversation examples, with string message content. TRL trains completion examples on the completion only. Conversations are trained on every turn, prompts included, because June's runner doesn't turn on TRL's assistant_only_loss. Preference examples aren't supported yet.
An adapter isn't a model you can call by itself. Load it on top of baseModel with PEFT, or serve it with a server that supports LoRA adapters (vLLM, TGI, llama.cpp after conversion), and point an AI SDK provider at that server to use it from June.
The Python dependencies
The npm package stays small because the training never runs in JavaScript. June writes the data and a config to disk and starts its Python runner as a separate process; the runner declares its dependencies inline, so uv installs exactly these, in an isolated environment:
| Package | Why |
|---|---|
torch | runs the model |
transformers | loads the model and tokenizer |
trl | the supervised fine-tuning loop (SFTTrainer) |
peft | LoRA adapters |
datasets | SFTTrainer takes its data as a datasets table |
bitsandbytes | 4-bit quantization; added only for qlora |
accelerate comes with trl. bitsandbytes needs a CUDA GPU, which is why it's added only for QLoRA (with uv run --with bitsandbytes) instead of always.
Runs and their files
Each run gets a directory under .june/trl (change it with trl({ dir }); add .june to .gitignore), named after the run's name and job id:
| File | Written by | What |
|---|---|---|
config.json | June | the model, method, hyperparameters and data files |
train.jsonl | June | the training examples in TRL's format |
validation.jsonl | June | the validation examples, if any |
runner.py | June | the exact runner script used |
pid | June | the runner's process id and start time, so a reused pid isn't mistaken for it |
runner.log | the runner | its stdout and stderr |
status.json | the runner | { state, progress, error }, updated as it trains |
result.json | the runner | { artifact: { type, format, path }, metrics } when done |
output/ | the runner | the adapter or checkpoint |
The runner keeps going if your process exits, and its state is all in these files. So a job can be checked on, waited for, or canceled from another process with fineTuneJob(trl(), ref); see Long-running fine-tunes. Canceling sends the runner's process group SIGTERM.
When a run fails, the job's error carries the runner's error and the last 20 lines of runner.log in error.details.log.
Reproducibility
The run directory holds everything that went into the run: the config, the exact data, and the runner script. The manifest records the base model, method, hyperparameters and dataset fingerprints. Set config.seed for repeatable shuffling and initialization; GPU training still isn't bit-for-bit deterministic.
Custom runners
trl({ runner: "/home/me/project/my-runner.py" }) runs your script instead of June's. Like python, it must be an absolute path. It's run as <python> <runner> config.json in the run directory, and must follow the same contract: read config.json, write status.json (atomically: write a temp file and rename it) as it goes, and result.json before reporting succeeded.
Testing
June's own tests run a fixture runner that follows this contract without training. A smoke test trains a tiny model with real TRL. It needs uv and a network connection, and is skipped unless you ask for it:
JUNE_TRL_SMOKE=1 make test path=packages/june/src/trainers