Skip to content

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:

  1. There's an eval suite for the behavior, and the current model has a baseline on it. If the baseline already does well, stop.
  2. 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.
  3. The gap is stable. It shows on held-out cases across runs, not on one noisy run or a handful of cases.
  4. 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.
  5. 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

JuneThe trainer
Canonical datasets and their fingerprintsConverting examples to its own format
Checking the trainer supports the methodRunning the training, wherever it runs
Starting the job and polling it until it endsReporting the job's status and progress
Canceling the job when your signal abortsStopping the job when asked
A JSON manifest of what was trained, from whatThe 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:

  1. Check. June normalizes method to a config object and rejects, before anything starts, if the trainer's capabilities.methods doesn't include it.
  2. Fingerprint. June summarizes the train and validation datasets: their source, size and fingerprint.
  3. 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.
  4. Poll. June asks the trainer for the job's status every pollInterval until it's succeeded, failed or canceled, reporting each status to onStatus. The trainer's backend owns the job's real state; June keeps only the reference.
  5. 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 a JobFailedError.

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
modelthe base model, in whatever form the trainer expects
trainera Trainer
dataseta Dataset, or { train, validation }
method"sft", "lora", "qlora", "dpo", "preference", "reinforcement", or a config such as { type: "lora", rank: 32, alpha: 64 }
configepochs, learningRate, batchSize, maxSequenceLength, seed
namea name for the run, passed to the trainer and recorded in the manifest
providerOptionsoptions for one trainer, namespaced by its id: { vertex: { adapterSize: 4 } }
signalcancels the job and rejects with the abort reason
pollIntervalmilliseconds between status checks (5000 by default)
onStatuscalled 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:

typeWhat it isFields
adapterweights to load on top of the base model, such as a LoRAformat, uri
checkpointa complete set of weightsformat, uri
provider-modela model that only exists inside a provider, called by its idprovider, 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 adapter is loaded on top of baseModel: 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 checkpoint is a full model; serve it like any other open-weight model.
  • A provider-model is called through the provider's own AI SDK package, by its modelId. 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.