Fine-tuning
june/train fine-tunes models through a small, provider-neutral contract. June orchestrates training; it doesn't train anything itself. The weights, GPUs and provider APIs belong to a trainer.
When to fine-tune
Fine-tuning isn't the default way to improve an agent. Fix prompting, retrieval, tool behavior, data quality and eval coverage first. Fine-tune when held-out evals show a stable behavior gap and you have enough representative examples to teach it.
Before you train, check each of these:
- There's an eval suite for the behavior, and the current model has a baseline on it. If the baseline already does well, stop.
- Cheaper candidates have plateaued. A better prompt, examples in the prompt, retrieval, a tool fix or another model were each compared on the same suite, and the gap stayed.
- The gap is stable. It shows on held-out cases across runs, not on one noisy run or a handful of cases.
- You have enough good examples of the behavior, split so the test set is never trained on. Fine-tuning suits behavior that's hard to describe but easy to show: a consistent style, format or voice.
- You can serve and keep the result. A tuned model is one more artifact to host, evaluate and replace; see Serving the artifact.
Don't fine-tune to teach facts that change (retrieve them), to fix a tool the model calls wrong (fix the tool or its description), or because a training loss went down. A lower loss says the model fits its training data, not that the product got better; only the held-out comparison says that.
After training, evaluate the tuned model on the same held-out cases and compare it with the baseline before anyone promotes it. June doesn't decide when to train or whether a tuned model is better; it gives you the data, the job and the evidence to decide with.
What June owns, and what trainers own
| June | The trainer |
|---|---|
| Canonical datasets and their fingerprints | Converting examples to its own format |
| Checking the trainer supports the method | Running the training, wherever it runs |
| Starting the job and polling it until it ends | Reporting the job's status and progress |
Canceling the job when your signal aborts | Stopping the job when asked |
| A JSON manifest of what was trained, from what | The artifact itself, and any provider-side provenance |
Swapping one trainer for another doesn't change the dataset, the call or the result's shape. It can change everything else: which methods and example shapes are supported, which hyperparameters are honored, what kind of artifact comes back, and where your data goes. See Local or managed.
The lifecycle
A fine-tune goes through the same steps whichever trainer runs it:
- Check. June normalizes
methodto a config object and rejects, before anything starts, if the trainer'scapabilities.methodsdoesn't include it. - Fingerprint. June summarizes the train and validation datasets: their
source, size and fingerprint. - Start. The trainer converts the examples to its own format, checks what it can't train on, and starts a job. It returns a JSON reference to that job.
- Poll. June asks the trainer for the job's status every
pollIntervaluntil it'ssucceeded,failedorcanceled, reporting each status toonStatus. The trainer's backend owns the job's real state; June keeps only the reference. - Finish. On success June checks the artifact's type is in the trainer's
capabilities.artifacts, and builds the manifest, which it returns with the artifact for you to store. On failure or cancellation it rejects with aJobFailedError.
Steps 1 to 3 are startFineTune(); 4 and 5 are its job's result(). Because the job is a reference you can save, the steps can happen in different processes.
fineTune(options)
import { dataset, fineTune } from "june/train";
const result = await fineTune({
name: "fonzie",
model: "Qwen/Qwen3-8B",
trainer,
dataset: dataset([
{ type: "completion", prompt: "Say hi", completion: "Hey there." },
]),
method: { type: "lora", rank: 32 },
});
result.artifact; // what the trainer produced
result.manifest; // how it was produced
| Option | |
|---|---|
model | the base model, in whatever form the trainer expects |
trainer | a Trainer |
dataset | a Dataset, or { train, validation } |
method | "sft", "lora", "qlora", "dpo", "preference", "reinforcement", or a config such as { type: "lora", rank: 32, alpha: 64 } |
config | epochs, learningRate, batchSize, maxSequenceLength, seed |
name | a name for the run, passed to the trainer and recorded in the manifest |
providerOptions | options for one trainer, namespaced by its id: { vertex: { adapterSize: 4 } } |
signal | cancels the job and rejects with the abort reason |
pollInterval | milliseconds between status checks (5000 by default) |
onStatus | called with the job's state and progress after every check |
fineTune() rejects before starting anything when the trainer's capabilities.methods doesn't include the method. It rejects with a JobFailedError when the job fails or is canceled; error.job holds the job's ref, state and the trainer's error.
It resolves with:
import type {
ModelArtifact,
TrainingJobSnapshot,
TrainingManifest,
} from "june/train";
interface FineTuneResult {
artifact: ModelArtifact;
metrics?: Record<string, number>; // as the trainer reported them
manifest: TrainingManifest;
job: TrainingJobSnapshot; // { id, trainer, state, progress?, error? }
}
Long-running fine-tunes
A fine-tune can take hours, longer than the process that started it. startFineTune() takes the same options as fineTune() without the waiting ones, starts the job and returns a Job right away. Save its ref, and rebuild the job from it with fineTuneJob() in any process, with the same trainer:
import {
dataset,
fineTuneJob,
startFineTune,
type FineTuneRef,
} from "june/train";
declare function save(ref: FineTuneRef): Promise<void>;
declare function load(): Promise<FineTuneRef>;
const started = await startFineTune({
model: "Qwen/Qwen3-8B",
trainer,
dataset: dataset("./fonzie.jsonl"),
method: "lora",
});
await save(started.ref);
// later, maybe after a restart
const job = fineTuneJob(trainer, await load());
await job.status(); // { state: "running", progress: { fraction: 0.4 } }
const { artifact, manifest } = await job.result();
The ref is plain JSON: the trainer's job, plus the run's name, base model, method, config, dataset summaries and start time, so the manifest comes out the same however many processes it took. fineTune() is startFineTune() followed by job.result(), plus canceling the job if its signal aborts.
Artifacts
A fine-tune doesn't always produce a model you can call. The artifact says what you got:
type | What it is | Fields |
|---|---|---|
adapter | weights to load on top of the base model, such as a LoRA | format, uri |
checkpoint | a complete set of weights | format, uri |
provider-model | a model that only exists inside a provider, called by its id | provider, modelId |
Every artifact has baseModel, and may have metadata: provider-owned JSON namespaced by provider id, such as { vertex: { endpoint: "…" } }. June keeps it as is and never reads inside it.
Serving the artifact
June stops at the artifact. June can't serve a tuned model for you: it doesn't host, load or serve models, so using one is up to you and the provider:
- An
adapteris loaded on top ofbaseModel: with PEFT in Python, or by a server that supports LoRA adapters, such as vLLM. Point an AI SDK provider (for example an OpenAI-compatible one) at that server. - A
checkpointis a full model; serve it like any other open-weight model. - A
provider-modelis called through the provider's own AI SDK package, by itsmodelId. It only exists inside that provider.
Once it's served, a tuned model is an ordinary AI SDK model or function, so evaluate() and the rest of June use it like any other, and it's ready to compare with the baseline. Code that serves artifacts from more than one trainer looks at artifact.type.
The manifest
The manifest is plain JSON to store next to the artifact:
{
"version": 1,
"name": "fonzie",
"trainer": "trl",
"baseModel": "Qwen/Qwen3-8B",
"method": { "type": "lora", "rank": 32 },
"config": { "epochs": 2 },
"datasets": {
"train": { "source": "memory", "size": 1, "fingerprint": "sha256:9f2c…" }
},
"job": { "id": "trl-3f1a", "trainer": "trl" },
"artifact": {
"type": "adapter",
"format": "peft",
"uri": "/runs/fonzie/adapter",
"baseModel": "Qwen/Qwen3-8B"
},
"metrics": { "loss": 0.42 },
"startedAt": "2026-10-14T03:00:00.000Z",
"completedAt": "2026-10-14T03:41:12.000Z"
}
providerOptions aren't recorded, since they can hold account details. A trainer that needs provenance of its own puts it in artifact.metadata.
Writing a trainer
A trainer is an object with an id, its capabilities, and three calls. It needs nothing but the types from june/train:
import type { Trainer } from "june/train";
declare function submit(body: object): Promise<{ id: string }>;
declare function poll(id: string): Promise<{
state: "pending" | "running" | "done" | "error";
model?: string;
message?: string;
}>;
declare function stop(id: string): Promise<void>;
export const myTrainer: Trainer = {
id: "my-cloud",
capabilities: { methods: ["sft"], artifacts: ["provider-model"] },
async start(request) {
const examples = await request.train.examples();
const { id } = await submit({
model: request.model,
examples,
...request.providerOptions["my-cloud"],
});
return { id, trainer: "my-cloud" };
},
async status(job) {
const remote = await poll(job.id);
switch (remote.state) {
case "pending":
return { state: "queued" };
case "running":
return { state: "running" };
case "error":
return {
state: "failed",
error: { name: "RemoteError", message: remote.message ?? "" },
};
case "done":
return {
state: "succeeded",
result: {
artifact: {
type: "provider-model",
provider: "my-cloud",
modelId: remote.model!,
baseModel: "base",
},
},
};
}
},
async cancel(job) {
await stop(job.id);
},
};
start returns a TrainingJob: a JSON reference (id, trainer, and optional namespaced metadata) that status and cancel can use from any process. Keep whatever the trainer needs to find the job again in it.
This is intentionally a small abstraction. It has no retries, queues, storage or provider SDKs; a trainer brings whatever its backend needs. Custom trainers covers capabilities, jobs and artifacts in full.