# Managed training

`vertex()` tunes Gemini models on [Vertex AI](https://cloud.google.com/vertex-ai/generative-ai/docs/models/gemini-use-supervised-tuning) with supervised fine-tuning. It's the same `fineTune()` call as [local training](https://june.dev/docs/training/local.md), 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.

```ts
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](https://june.dev/docs/training/local.md) keeps it all on your machine.

## A first fine-tune

```ts
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           |                                                                             |
| ---------------- | --------------------------------------------------------------------------- |
| `project`        | the Google Cloud project id                                                 |
| `region`         | where to tune; `us-central1` by default                                     |
| `staging`        | a `gs://bucket/prefix` for uploaded datasets                                |
| `accessToken`    | a fixed OAuth token                                                         |
| `getAccessToken` | called before every request for a fresh token; one of these two is required |
| `retry`          | `{ attempts, delay }`; see [Retries](#retries)                              |
| `baseURL`        | the Vertex API; `https://{region}-aiplatform.googleapis.com/v1` by default  |
| `storageURL`     | the 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                   |                                           |
| ------------------------ | ----------------------------------------- |
| `adapterSize`            | the LoRA adapter size: 1, 4, 8 or 16      |
| `learningRateMultiplier` | scales Vertex's recommended learning rate |
| `labels`                 | labels on the tuning job                  |

```ts
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:

```ts
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:

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