Skip to content

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:

MachineWhat's realistic
CPU or Apple Siliconmodels under about 1B parameters, slowly: enough to try the pipeline end to end
a 24 GB NVIDIA GPULoRA on an 8B model in bf16, roughly
a smaller NVIDIA GPU, around 10 GBQLoRA on an 8B model, at some cost in speed; QLoRA needs CUDA
several large GPUsfull 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

MethodWhat it trainsArtifact
sftevery weight of the modelcheckpoint in transformers format
loraa LoRA adapter on top of the frozen base modeladapter in peft format
qloraa LoRA adapter on a 4-bit quantized base modeladapter 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:

PackageWhy
torchruns the model
transformersloads the model and tokenizer
trlthe supervised fine-tuning loop (SFTTrainer)
peftLoRA adapters
datasetsSFTTrainer takes its data as a datasets table
bitsandbytes4-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:

FileWritten byWhat
config.jsonJunethe model, method, hyperparameters and data files
train.jsonlJunethe training examples in TRL's format
validation.jsonlJunethe validation examples, if any
runner.pyJunethe exact runner script used
pidJunethe runner's process id and start time, so a reused pid isn't mistaken for it
runner.logthe runnerits stdout and stderr
status.jsonthe runner{ state, progress, error }, updated as it trains
result.jsonthe runner{ artifact: { type, format, path }, metrics } when done
output/the runnerthe 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