Training in workflows
fineTune() and evaluate() are the canonical APIs. Use <FineTune> and <Evaluate> only when training or evaluation is itself one stage of a larger June workflow, and should take part in its dependencies, trace, cancellation and composition. Don't wrap a fine-tune in JSX just because JSX exists: a script that trains and evaluates once is clearer as two awaited calls.
Imperative or JSX
| Use | When |
|---|---|
fineTune(), evaluate() | scripts, servers, tests, and anywhere you don't need a workflow |
startFineTune(), Job | jobs you start in one process and check on in another |
<FineTune>, <Evaluate> | training as one part of a larger workflow: preparing data, training, evaluating, deploying |
In a workflow, training gets what every step gets: a trace node, results in steps and --json, snapshots, <Catch>, the run's signal and timeout, and its concurrency limit.
The components are thin JSX over the same calls. A long-running fine-tune is still a job underneath: the trainer runs it in its own process or provider, and <FineTune> only starts it, polls it and renders its children with the result. JSX doesn't make the training run inside React.
A workflow that trains, then evaluates the tuned model next to the base model:
import { openai } from "june/sdk/openai";
import { Evaluate, FineTune, dataset, type EvalSuite } from "june/train";
import { trl } from "june/trainers";
import type { LanguageModel } from "ai";
declare const suite: EvalSuite<string, string, string>;
declare function serve(uri: string): LanguageModel;
const data = dataset("./fonzie.jsonl").split({
train: 0.9,
validation: 0.1,
seed: 42,
});
export default function TrainVoice() {
return (
<FineTune
id="fonzie"
model="Qwen/Qwen3-8B"
trainer={trl()}
dataset={data}
method={{ type: "lora", rank: 32 }}
>
{(result) => (
<>
<Evaluate
id="tuned-eval"
name="tuned"
target={serve(
result.artifact.type === "adapter" ? result.artifact.uri : "",
)}
suite={suite}
/>
<Evaluate
id="base-eval"
name="base"
target={openai("gpt-5-mini")}
suite={suite}
/>
</>
)}
</FineTune>
);
}
june run train-voice.tsx
<FineTune>
Takes fineTune()'s options (model, trainer, dataset, method, config, providerOptions, pollInterval) plus:
| Prop | |
|---|---|
id | the step's id |
children | rendered once training succeeds; a function child receives the FineTuneResult |
onJob | called with the job's ref once it starts, to save for resume |
resume | a saved ref: wait on that job instead of starting another |
Children render only after the job succeeds, so evals and anything that serves the artifact depend on it like any other step result. A failed or canceled job fails the step with a JobFailedError, and its children never render; wrap it in <Catch> to handle that.
<Evaluate>
Takes id, target, suite, and optionally name, concurrency and cacheKey. Its result is the EvalRun; a function child receives it. A target can be any AI SDK model, including a tuned one, or a function.
Its cache key covers the suite's cases, the graders' names, the target's model id or function name, name, and cacheKey. Under a <FineTune>, it also covers that fine-tune's key, so a retrain on new data re-runs the eval instead of reusing an old snapshot result. June can't see what a grader or function target does, so change cacheKey when they change.
Trace
Both render a node with the usual step fields (status, startedAt, endedAt, durationMs, error, result) plus:
| Field | |
|---|---|
job | <FineTune>: { id, kind, state }, the job's id and normalized state |
progress | { fraction, step, totalSteps, message, metrics } as the trainer reports, or <Evaluate>'s finished cases |
june run prints job state changes and progress as they happen:
▸ fonzie
↳ fine-tune job trl-3f1a9c2e queued
↳ fine-tune job trl-3f1a9c2e running
↳ 120/400
↳ 400/400
↳ fine-tune job trl-3f1a9c2e succeeded
✓ fonzie
▸ tuned-eval
↳ 50/50
✓ tuned-eval
The trace carries the job's id, state, progress and the result, never the request: providerOptions, datasets and anything else passed to the trainer stay out of it.
Cancellation
Aborting the run, through its signal, timeout, or handle.abort(), cancels the trainer's job, and so does a failure elsewhere in the run. The cancel starts at once, even while a status check is in flight, and the run rejects only once it's been sent (or after 30 seconds), so june run exiting on Ctrl-C or --timeout doesn't leave a paid job running. A cancel that fails doesn't replace the run's error. Unmounting a <FineTune> under a <Catch> that falls back cancels it the same way.
Durability
A run lives in one process, but a fine-tune doesn't have to. Two things carry training across a restart:
- Snapshots. A finished
<FineTune>'s result is in the run'ssnapshot(). Its cache key covers the model, trainer, method, config, provider options and the datasets' fingerprints, so a resumed run reuses the artifact only when it would train on the same data the same way. - Job refs. A fine-tune that was still running when the process died isn't in the snapshot. Save its ref with
onJob, and pass it back asresume, and the step waits on that job instead of training again:
import { FineTune, dataset, type FineTuneRef } from "june/train";
import { trl } from "june/trainers";
declare function saveRef(ref: FineTuneRef): void;
declare const savedRef: FineTuneRef | undefined;
export default function Train() {
return (
<FineTune
id="fonzie"
model="Qwen/Qwen3-8B"
trainer={trl()}
dataset={dataset("./fonzie.jsonl")}
method="lora"
onJob={saveRef}
resume={savedRef}
/>
);
}
Whether a job can be found again depends on its trainer: trl() keeps its state in files and vertex() in Vertex, so both can. June has no database; storing snapshots and refs is up to you. See Jobs for how refs work, and Fine-tuning for the same without a workflow.