Skip to content

Managed training

vertex() tunes Gemini models on Vertex AI with supervised fine-tuning. It's the same fineTune() call as local training, with a different trainer. What changes is who holds your data and your model: Google trains in your project, and the tuned model lives in Vertex.

Vertex AI was picked as the first managed trainer because its tuning API is generally available, takes plain JSONL from Cloud Storage, and returns a model that only exists inside the provider, which exercises the provider-model artifact. OpenAI's fine-tuning platform no longer takes new users.

Setup

  1. A Google Cloud project with the Vertex AI API enabled.
  2. A Cloud Storage bucket in the same project for the uploaded datasets: staging is a gs:// prefix in it.
  3. An identity that can create tuning jobs (roles/aiplatform.user) and write to the bucket (roles/storage.objectCreator).

Authentication

June doesn't depend on Google's SDKs. It calls the Vertex and Cloud Storage REST APIs with fetch, using an OAuth access token you supply:

  • accessToken: a fixed token, fine for a short script.
  • getAccessToken(): called before every request, so it can hand back a fresh token. Tuning jobs outlast a token's hour.

Get one however suits your environment: gcloud auth print-access-token on a laptop, the metadata server on Google Cloud, or google-auth-library in your own code. Long-lived credentials, such as a service account key, stay with whatever mints the token; June only ever sees short-lived access tokens. It holds a fixed accessToken in memory on the trainer, calls getAccessToken() again for every request rather than caching its result, and sends tokens only in the Authorization header. They never appear in job refs, manifests or errors, and June doesn't write them anywhere.

import { vertex } from "june/trainers";

const trainer = vertex({
  project: "acme-ml",
  staging: "gs://acme-tuning/june",
  getAccessToken: async () =>
    (await Bun.$`gcloud auth print-access-token`.text()).trim(),
});

Data leaves your machine

Managed training uploads your datasets to Google, and Google trains on them. Before you run the example below, know what goes where:

  • Your examples, converted to Gemini's tuning format, are uploaded to <staging>/<name>-<id>/train.jsonl and, if you pass a validation set, validation.jsonl in your Cloud Storage bucket. June doesn't delete them when the job ends; remove them yourself, or set a lifecycle rule on the bucket, if they shouldn't stay.
  • The tuning job runs in your Google Cloud project, on Google's infrastructure.
  • The tuned model exists only in Vertex, as an endpoint in your project. It never comes back to your machine.

Check that's acceptable for the data before you use it. Local training keeps it all on your machine.

A first fine-tune

import { dataset, fineTune } from "june/train";
import { vertex } from "june/trainers";

declare function getAccessToken(): Promise<string>;

const result = await fineTune({
  name: "fonzie",
  model: "gemini-2.5-flash",
  trainer: vertex({
    project: "acme-ml",
    region: "us-central1",
    staging: "gs://acme-tuning/june",
    getAccessToken,
  }),
  dataset: dataset("./fonzie.jsonl"),
  method: "sft",
  config: { epochs: 3 },
});

result.artifact;
// {
//   type: "provider-model",
//   provider: "vertex",
//   modelId: "projects/acme-ml/locations/us-central1/endpoints/4567",
//   baseModel: "gemini-2.5-flash",
//   metadata: { vertex: { tuningJob: "…/tuningJobs/123", endpoint: "…", model: "…" } }
// }

Options

Option
projectthe Google Cloud project id
regionwhere to tune; us-central1 by default
staginga gs://bucket/prefix for uploaded datasets
accessTokena fixed OAuth token
getAccessTokencalled before every request for a fresh token; one of these two is required
retry{ attempts, delay }; see Retries
baseURLthe Vertex API; https://{region}-aiplatform.googleapis.com/v1 by default
storageURLthe Cloud Storage API; https://storage.googleapis.com by default

Methods, models and options

Vertex supports sft on text examples: completion and conversation, without tool messages. Which base models can be tuned, how many examples they need, and their limits are up to Google; see Vertex's list of tunable models.

config.epochs sets the epoch count. Vertex ignores the rest of config (learningRate, batchSize, maxSequenceLength, seed) and every method option, since its tuning API doesn't take them. Vertex-specific settings go in providerOptions.vertex:

Option
adapterSizethe LoRA adapter size: 1, 4, 8 or 16
learningRateMultiplierscales Vertex's recommended learning rate
labelslabels on the tuning job
import { dataset, fineTune } from "june/train";

await fineTune({
  model: "gemini-2.5-flash",
  trainer,
  dataset: dataset("./fonzie.jsonl"),
  method: "sft",
  providerOptions: { vertex: { adapterSize: 4, learningRateMultiplier: 0.5 } },
});

Vertex picks its recommended values for anything unset. Other trainers ignore the vertex namespace, so one call can carry options for several trainers.

What happens

  1. Each dataset is converted to Gemini's tuning format and uploaded to <staging>/<name>-<id>/train.jsonl (and validation.jsonl). Completion examples become a user turn and a model turn; a conversation's system messages become its systemInstruction.
  2. June creates a tuning job with the uploaded files and the hyperparameters, and polls it until it settles.
  3. On success, the artifact's modelId is the tuned model's endpoint, which is what you call it by. The tuning job, endpoint and model resource names are in artifact.metadata.vertex.

Vertex job states map to June's: pending and queued are queued; running, paused, updating and cancelling are running; failed, expired and partially succeeded are failed.

Retries

June retries a small, fixed set of responses, up to 3 attempts with exponential backoff from 500 ms (vertex({ retry: { attempts, delay } })), honoring Retry-After up to a minute:

  • 429 and 503, for every request, since Vertex rejects those before doing anything.
  • 500, 502 and 504, and network errors, only for uploads, status checks and cancels, which are safe to repeat. Creating a tuning job isn't retried on those, so a timeout can't start two jobs.

Anything else fails straight away with Vertex's error message.

Provider model artifacts

A provider-model artifact is opaque: it only works inside the provider that made it. Call it with the provider's own AI SDK package and the artifact's modelId; June doesn't ship one. Unlike a local adapter, you can't download it or move it to another provider, and deleting the endpoint in Vertex deletes the model.

Swapping trainers

Datasets, evals and the fineTune() call don't change between trainers. Only the trainer and its namespaced options do:

import { dataset, fineTune } from "june/train";
import { trl, vertex } from "june/trainers";

declare const local: boolean;

const data = dataset("./fonzie.jsonl");
const { artifact } = await fineTune({
  model: local ? "Qwen/Qwen3-8B" : "gemini-2.5-flash",
  trainer: local
    ? trl()
    : vertex({
        project: "acme-ml",
        staging: "gs://acme-tuning/june",
        accessToken: "…",
      }),
  dataset: data,
  method: "sft",
});

The artifact differs (an adapter or checkpoint from TRL, a provider-model from Vertex), so code that serves the tuned model looks at artifact.type.

Testing

June's tests run the real adapter, over real fetch, against a local HTTP server that emulates Cloud Storage uploads and the tuning API. A smoke test creates a real tuning job and cancels it straight away; it's skipped unless you set JUNE_VERTEX_SMOKE to <project>,gs://<bucket>/<prefix> and are logged in with gcloud:

JUNE_VERTEX_SMOKE=acme-ml,gs://acme-tuning/june make test path=packages/june/src/trainers/vertex