Docs

# Fine-tuning API

Train an open model on your own conversations and get back a model you can download or run in batches. The API is OpenAI's fine-tuning API, so your existing code carries over.

## How it works

You send examples and Gridline trains the model: there is no GPU to choose and no training code to write. A job takes three steps.

1. Upload your examples as chat JSONL, with `purpose=fine-tune`.
2. Create a job with `POST /fine_tuning/jobs`, naming a base model and your training file. The response carries the job's quote.
3. Wait for `succeeded`. The job's `fine_tuned_model` is your `ft:` model.

Requests follow OpenAI's API at Gridline's base URL, with a Gridline token as the API key. The [quickstart](https://gridlinegpu.com/docs#access) shows how to get one.

```
https://api.gridlinegpu.com/openai/v1
```

If you would rather run your own training script, see [your own script](https://gridlinegpu.com/docs/own-script).

## Base models

These are the models you can fine-tune. All are Apache-2.0 except Meta's Llama 3.1 8B Instruct, which comes under the Llama 3.1 Community License.

| Base model id | Source | Licence | Max lora_rank (lora, qlora) | Max lora_rank (DPO) | Batches on its ft: models |
| --- | --- | --- | --- | --- | --- |
| `qwen2.5-0.5b-instruct` | Qwen/Qwen2.5-0.5B-Instruct | Apache-2.0 | 64 | 64 | Yes |
| `qwen2.5-1.5b-instruct` | Qwen/Qwen2.5-1.5B-Instruct | Apache-2.0 | 64 | 57 | Yes |
| `qwen2.5-7b-instruct` | Qwen/Qwen2.5-7B-Instruct | Apache-2.0 | 64 | 26 | Yes |
| `mistral-7b-instruct-v0.3` | mistralai/Mistral-7B-Instruct-v0.3 | Apache-2.0 | 64 | 25 | Yes |
| `qwen3-8b` | Qwen/Qwen3-8B | Apache-2.0 | 64 | 24 | Yes |
| `llama-3.1-8b-instruct` | RedHatAI/Llama-3.1-8B-Instruct | Llama 3.1 Community License | 64 | 25 | Yes |
| `gpt-oss-20b` | openai/gpt-oss-20b | Apache-2.0 | 64 | 64 | Not yet: download only |
| `qwen3.5-9b` | Qwen/Qwen3.5-9B | Apache-2.0 | 36 | 36 | Not yet: download only |

Exact model versions

Each base is pinned to a full Hugging Face commit. A job trains on that commit and batches serve the same one (once batch serving covers the base).

- `qwen2.5-0.5b-instruct`: `7ae557604adf67be50417f59c2c2f167def9a775`
- `qwen2.5-1.5b-instruct`: `989aa7980e4cf806f80c7fef2b1adb7bc71aa306`
- `qwen2.5-7b-instruct`: `a09a35458c702b33eeacc393d103063234e8bc28`
- `mistral-7b-instruct-v0.3`: `c170c708c41dac9275d15a8fff4eca08d52bab71`
- `qwen3-8b`: `b968826d9c46dd6066d109eabc6255188de91218`
- `llama-3.1-8b-instruct`: `83c927477af31b2c66de4c0210ae5fa04b28289b`
- `gpt-oss-20b`: `6cee5e81ee83917806bbde320786a8fb61efebee`
- `qwen3.5-9b`: `c202236235762e1c871ad0ccb60c8ee5ba337b9a`

### Things to know about each model

Max lora_rank is the largest rank whose adapter fits the trainer's adapter limit: 383 MiB for LoRA and QLoRA (`method` `lora` or `qlora`), and the trainer's 128 MiB adapter limit for DPO. On the 7B and 8B bases, QLoRA runs on a 48 GB GPU at any rank up to 64 and is charged the LoRA rate, so it costs the same as bf16 LoRA. There, bf16 LoRA (the default) trains faster and fits more training tokens in a job; QLoRA is the default on the smaller bases.

`mistral-7b-instruct-v0.3` needs every row to alternate `user` and `assistant` after an optional first `system` message; its chat template refuses anything else.

`llama-3.1-8b-instruct` is Meta's Llama 3.1 8B Instruct, staged from Red Hat's ungated copy of the same files. Its chat template starts every row with a system turn that holds two date lines, before your own system message. Tool rows are not supported on it. Built with Llama: your use of it and of its fine-tunes is subject to the [Llama 3.1 Community License](https://www.llama.com/llama3_1/license/) and [Acceptable Use Policy](https://www.llama.com/llama3_1/use-policy/).

A qwen3-8b fine-tune made before batch serving of qwen3-8b began kept its LoRA adapter for download and created no fine_tuned_model: that job's fine_tuned_model is null and gridline.fine_tuned_adapter names the adapter (servable false). A new job on the same files creates a fine-tuned model you can run batches on.

`qwen3-8b` trains in Qwen3's non-thinking chat format. An assistant turn may carry a `<think>…</think>` block only as the last turn, in the template's own form; anywhere else create refuses the file with `template_token_in_content`. Tool rows are not supported on it.

## Training files

Upload with `purpose=fine-tune`. Each line is one conversation in OpenAI's chat format, a `messages` list:

```
{"messages": [{"role": "system", "content": "Classify the ticket."}, {"role": "user", "content": "The parcel never came."}, {"role": "assistant", "content": "delivery"}]}
```

- Roles are `system` (first message only), `user` and `assistant`, each with non-empty string `content`. Every row needs at least one assistant message to train on.
- An assistant message may carry OpenAI's `weight`: `0` keeps it as context without training on it, `1` (the default) trains on it.
- `tools`, `functions`, `name`, `tool_calls`, content parts and the `tool` and `function` roles are refused by name, never ignored.
- Each line is at most 1 MiB, with at most 1,024 messages. A job's training and validation files are each at most 64 MiB.
- Keep chat-template markup out of your text. The job renders each row with the base's chat template and then looks for every message in the result. Create reads both files first. It refuses the job with `400 template_token_in_content`, naming the line, the message and the token, when a message holds one of the base's control tokens as text, such as `<|im_end|>` on the Qwen bases, or holds an assistant `</think>` outside the template's own form on the Qwen3 bases. Documentation, chat logs and code often contain such strings. Anything else the template changes or drops still fails the job with `template_mismatch`, after a GPU has started.

The file is checked as it uploads. The first invalid line fails it, with that line's number and a code such as `invalid_json`, `invalid_messages` or `line_too_large`. A valid file records its row and message counts, an upper bound on its tokens from its UTF-8 bytes, and a conservative estimate of its tokens for each base, from its text. The job counts tokens exactly with the base's tokenizer before it trains.

### From batch results

You can train on the answers a batch produced, for example to teach a small model what a large one wrote. `POST /v1/files/distill` turns a batch's requests and results into a training file and a held-out validation file, both `purpose=fine-tune`.

- `results_file` is a batch's `output_file_id` (its requests are that batch's input file) or an uploaded results file in the OpenAI or Anthropic batch format with its `requests_file`.
- Each result becomes one row: the request's messages plus the answer. Errored, refused, truncated and empty answers are dropped and counted.
- Options: `require_json`, `keep_truncated`, `eval_fraction` (default 0.1) and `seed`; the same files and options always give the same rows.
- Each file it makes names its sources in `gridline.derived_from`, counts toward your file limits and is kept 30 days.

As a shortcut, creating a job with a batch's `output_file_id` as its `training_file` converts it with the defaults first.

You are responsible for having the right to train on what you upload, including model outputs: check the terms of the service that produced them.

## Create a job

Name a base model and your training file. Everything else has a default, and you can change the method or the settings in the same call:

```
job = client.fine_tuning.jobs.create(
    model="qwen2.5-7b-instruct",
    training_file="file-...",
    validation_file="file-...",  # optional
)

# LoRA instead of QLoRA, two epochs:
job = client.fine_tuning.jobs.create(
    model="qwen2.5-1.5b-instruct",
    training_file="file-...",
    method={"type": "supervised", "supervised": {"adapter": "lora", "hyperparameters": {"n_epochs": 2}}},
)
```

The same call with curl:

```
curl https://api.gridlinegpu.com/openai/v1/fine_tuning/jobs \
  -H "Authorization: Bearer $GRIDLINE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"model": "qwen2.5-7b-instruct", "training_file": "file-...", "method": "qlora"}'
```

The adapter method is bf16 LoRA by default on qwen2.5-7b-instruct, mistral-7b-instruct-v0.3, qwen3-8b and llama-3.1-8b-instruct, which train on 48 GB GPUs, and QLoRA by default on the smaller bases. "method": "qlora" on a 7B or 8B base also trains on a 48 GB GPU, at the same price as bf16 LoRA. gpt-oss-20b and qwen3.5-9b train with bf16 LoRA only, on 48 GB GPUs. Choose it with `method`: `"qlora"` or `"lora"` as a string, or OpenAI's `{"type": "supervised", "supervised": {...}}` object with an `adapter` field. Supervised fine-tuning and DPO (method.type "dpo", below) are offered; reinforcement is refused. Hyperparameters go in `hyperparameters` or `method.supervised.hyperparameters`, once:

| Hyperparameter | Range | Default |
| --- | --- | --- |
| `n_epochs` | 1 to 10 | 1 |
| `batch_size` | 1 to 64 | 8 |
| `learning_rate_multiplier` | 0.01 to 10, times 2e-4 | 1 |
| `learning_rate` | 1e-6 to 1e-2 (instead of the multiplier) | 2e-4 |
| `lora_rank` | 1 up to the base's max lora_rank (Base models) | 16 |
| `lora_alpha` | 1 to 256 | 32 |
| `max_seq_length` | 128 up to the model limit below | 2,048 |

Defaults: the method above, rank 16, alpha 32, one epoch, learning rate 2e-4. `learning_rate`, `lora_rank`, `lora_alpha` and `max_seq_length` are Gridline's; send them as dictionary keys (the OpenAI SDKs pass them through). `seed` and `metadata` work as in OpenAI's API. `suffix` (at most 64 characters) is accepted and ignored for now: the job records a `suffix_ignored` warning event. Any other field is refused by name.

Supervised fine-tuning supports `max_seq_length` up to 8,192 on qwen2.5-7b-instruct, qwen3-8b, llama-3.1-8b-instruct. Above 2,048, these jobs use a 48 GB GPU and support ranks up to 16. The quote shows `gpu_memory_class_gb: 48`. Long-context LoRA and QLoRA use the published per-token LoRA rate. DPO and the other bases support up to 2,048 tokens.

Create refuses a job, with `400 invalid_file_format` and a message naming the fix, when:

- the training file has fewer than 10 rows besides any held out for evaluation;
- a file is over 64 MiB;
- a Mistral file does not alternate roles.

Create also reads both files. It refuses a job with `400 template_token_in_content`, and a message naming the line, the message and the token, when a message holds a control token of the base's chat template written as text: `<|im_start|>`, `<|im_end|>` or `<|endoftext|>` on the Qwen bases; `<|eot_id|>` and the other header tokens on Llama; `<s>` and `</s>` on Mistral; each other base's own markers likewise. The trainer would read it as the template's own markup. On the Qwen3 bases an assistant `</think>` outside the template's own form is refused the same way. Rewrite the token (for example as plain words) and upload the file again. A job created before this check fails with `template_mismatch` before any GPU starts, and nothing is charged.

A job that could take longer than the 2-hour v1 limit, counting its training file's tokens with the base's estimate, is refused with `400 job_too_large`. The estimate is at or above the real count on ordinary text: on English, about 1.3 times the real count, or one token for about every 4 bytes. The message gives the limit, the job's duration at that count, how many training tokens fit and what to change (fewer rows or epochs, or a smaller base).

Changed in November 2026: this refusal used to be `400 invalid_file_format`. Clients that matched that code for the time limit should match `job_too_large`. The limit also used to count each content byte as a token, which refused jobs several times smaller than the limit allows. It still does for a file uploaded before the change: upload it again to have its tokens counted.

## Preference fine-tuning (DPO)

DPO trains on pairs of answers to the same prompt: one you prefer and one you don't. Send OpenAI's preference rows unchanged, one per line:

```
{"input": {"messages": [{"role": "user", "content": "Capital of France?"}]}, "preferred_output": [{"role": "assistant", "content": "Paris."}], "non_preferred_output": [{"role": "assistant", "content": "Lyon."}]}
```

`input.messages` holds an optional first system message, then user and assistant messages, and ends with a user message. Each output is one assistant message, and the two must differ. A file holds chat rows or preference rows, not both: a DPO job trains on preference rows and a supervised job on chat rows.

Create the job with `method.type` `"dpo"`:

```
job = client.fine_tuning.jobs.create(
    model="qwen2.5-7b-instruct",
    training_file="file-...",
    method={"type": "dpo", "dpo": {"hyperparameters": {"beta": 0.1}}},
)
```

`beta` is over 0 and at most 2, or `"auto"` (0.1). The learning rate is 5e-6 times `learning_rate_multiplier`, or an explicit `learning_rate` from 5e-8 to 1e-2. The other hyperparameters, the adapter (`method.dpo.adapter`) and the limits are the supervised job's.

A DPO job trains over the prompt, the preferred and the non-preferred answer, and each pair also runs a reference pass, so it trains about 2 times slower than supervised fine-tuning: the time limit fits about 1/2 as much data, and the quote counts it that way. Every DPO job trains on a 48 GB GPU. The job's eval loss is the DPO loss, which starts at 0.693 (ln 2) and falls as the model learns your preferences. DPO has its own rate on the [pricing page](https://gridlinegpu.com/pricing), charged on its training tokens: the prompt and both answers, every epoch.

## The quote

Every job tells you what it expects to cost before it runs. Creating a job starts it: there is no separate step to accept the quote. It comes back in the create response, in `gridline.quote`; to stop a job you do not want, cancel it. The time and token figures are estimates (`estimate: true`):

- `training_tokens_upper_bound` and `training_tokens_per_epoch_upper_bound`: tokens times epochs, an upper bound from the file's bytes.
- `n_epochs` and `eval_rows`.
- `estimated_seconds` (OpenAI's `estimated_finish` is the creation time plus this) and `estimated_usd`, a cost estimated from that time. Both count tokens with the base's estimate, as the time limit does. `estimated_usd` is not what you pay: a small job often shows less than the per-job minimum it is charged.
- `basis`: the estimate's assumptions, in words.
- `per_token`: what you pay. A job that succeeds is charged the tokens it trained, times the epochs, at `trained_usd_per_mtok`, and at least `minimum_usd`. The charge is never more than `max_charge_usd`, counted at the token upper bound, and the credit check holds at least that.

## The job

Once a job exists you can read it, follow its progress or stop it:

- `GET /fine_tuning/jobs/{id}` returns the job; `GET /fine_tuning/jobs` lists yours.
- `GET /fine_tuning/jobs/{id}/events` lists its progress, warnings and metrics.
- `POST /fine_tuning/jobs/{id}/cancel` stops it. A job that has ended, or is registering its adapter, answers `409 invalid_state`. Only a job that succeeds registers a model.

Statuses run `validating_files`, `queued`, `running`, then `succeeded`, `failed` or `cancelled`. Job ids are `ftjob-` followed by 32 hex characters.

Your account trains one fine-tuning job at a time. How many can be open at once is your account's limit. A new account can have 1 open, so a second create answers `429 limit_exceeded` until the first ends, with your limit in `error.limit`. On an account allowed more, the second job is accepted and waits in `validating_files` with a `waiting_for_capacity` event until it can start, for up to 24 hours.

A new job can then stay `queued` for a few minutes while Gridline finds and starts a GPU for it; at busy times it can take longer. You aren't charged for time spent waiting.

Queued

You aren't charged for time spent waiting.

Running

A job must finish within 2 hours.

Succeeded

The model is yours. Download it.

Waiting for a GPU is not charged. Check the job in your dashboard or its API response:` gridline.reason`, when available, explains the wait and what to do. A service health check does not guarantee that a GPU can start.

- Account capacity: wait for another job to end. An accepted job retries automatically; do not resubmit it.
- Customer credit: check your current balance before adding more. If you already added enough, wait for the next check. Resubmit only after a failed job.
- Gridline capacity: wait. Adding credit or submitting duplicates will not help.
- Platform daily limit: wait for capacity; the budget resets at 00:00 UTC.

New self-serve accounts normally allow 1 open fine-tune, 2 open batches and $100 per UTC day. Your account may have different limits. An account-limit check returns the effective limit and current use in `error.limit`. Other refusals, including a concurrent create that fills the last slot, may only have an error code and message. A refused job create does not create or charge a job. For an open-job limit, retry after a job ends. Daily limits reset at 00:00 UTC; a deposit does not raise them. Contact [[email protected]](https://gridlinegpu.com/cdn-cgi/l/email-protection) with your job ID to ask for help or a higher limit.

## Errors

A failed job carries `error.code` and `error.message`, and the same code on its last event:

| Code | Meaning |
| --- | --- |
| `insufficient_quota` | Not enough credit to start the job. Nothing ran. |
| `job_unavailable` | No GPU capacity for your account became free within 24 hours, or the job could not be started. Nothing ran and nothing was charged. |
| `invalid_row, template_mismatch` | A training row could not be read or rendered with the base's chat template. Nothing was trained. |
| `context_too_long` | No row keeps an assistant token within max_seq_length. Raise it or shorten the rows. |
| `time_limit_exceeded` | Training did not finish within the 2-hour v1 limit and was stopped by the platform. Nothing is charged. |
| `platform_interrupted` | The GPU running the job was lost and the job is not restarted. Under per-token billing nothing is charged; under pass-through billing the GPU time used is credited back. |
| `job_expired` | The job did not finish within its time limit. |
| `adapter_too_large` | The adapter is over its limit (383 MiB for lora and qlora, 128 MiB for DPO). Lower lora_rank. |
| `adapter_invalid` | The trained adapter is not a plain LoRA the model registry accepts. |
| `adapter_quota_exceeded` | Your account already holds 100 fine-tuned models. Delete some and run the job again. |
| `adapter_registration_failed` | The trained adapter could not be registered. |
| `training_failed` | Training failed for another reason. |

A job that fails for a platform reason (for example `training_failed` or `time_limit_exceeded`) is not charged. A job that fails for a cause on your side after training started, such as `adapter_quota_exceeded`, is charged for the tokens it trained, without the per-job minimum. More open jobs than your account allows answers `429 limit_exceeded` at create, with the limit in `error.limit` (see [The job](https://gridlinegpu.com/docs/fine-tuning#job)).

## Eval loss

Eval loss is how you tell whether training helped: lower after than before means the model got better at your examples. Every job measures it on held-out data before and after training, with the same tokenizer and chat template. It uses your `validation_file` when you give one; otherwise it holds out 5% of your training rows (at least 1 row and at most 1,000).

When the job succeeds, `gridline.result` reports `eval_loss_before`, `eval_loss` and `eval_rows`, with `eval_source` set to `validation_file` or `holdout`, and `train_loss`, `steps` and `seconds`.

The same figures appear as a `metrics` event in `GET /fine_tuning/jobs/{id}/events`. Each loss is the mean cross-entropy over the assistant tokens that train. `eval_rows` counts the rows actually evaluated: a row with no assistant token within `max_seq_length` is skipped, and when none is left both losses are `null`. `trained_tokens` is counted with the base's tokenizer.

A QLoRA adapter is trained against a 4-bit quantized base and served on the bf16 base, so its outputs can differ slightly from what training measured.

Eval loss tells you the model fits your examples better. To grade its answers, run the same [eval](https://gridlinegpu.com/docs/evals) on your `ft:` model and on its base, with string checks, text similarity or a model grader, billed as batches.

## Your ft: model

When a job succeeds, `fine_tuned_model` names the adapter it trained:

```
ft:<base-model-id>:<org>:<job-suffix>
ft:qwen2.5-7b-instruct:o3f9a1c2d4e5:7b2c9e41a0d3
```

- `<org>` is stable for your account and reveals nothing about it. It is not a credential: every use checks that the model is yours.
- `<job-suffix>` is the first 12 hex characters of the job id after `ftjob-`, and is unique within your account.
- `gridline.fine_tuned_adapter` gives the model, whether it is `servable`, and the `reason` when it is not. `fine_tuned_model` stays null until the adapter is servable.
- Fine-tunes of `gpt-oss-20b` and `qwen3.5-9b` are download-only for now: their `ft:` models cannot run in a batch yet, and `fine_tuned_model` stays null when the job succeeds. Download the adapter by the name in `gridline.fine_tuned_adapter.model`.
- `GET /models` lists your `ft:` models with the catalog, for you only; `GET /models/{model}` returns one.
- Download the adapter, two files, with your token: `GET /gridline/adapters/{model}/files/adapter_model.safetensors` and `.../files/adapter_config.json`, under the base URL (`https://api.gridlinegpu.com/openai/v1/gridline/adapters/...`). Each response carries the file's SHA-256 in `x-gridline-sha256`.
- `DELETE /models/{model}` deletes the model: from then on it cannot be named in a batch, listed or downloaded. The same request deletes its adapter files from storage; if a storage delete fails, the failure is recorded and the files stay unreachable through the API.
- Name it as `body.model` in a batch, alone or with up to 7 other `ft:` models of the same base; see the [Batch API](https://gridlinegpu.com/docs/batch-api#fine-tuned).

```
curl https://api.gridlinegpu.com/openai/v1/gridline/adapters/ft:qwen2.5-7b-instruct:o3f9a1c2d4e5:7b2c9e41a0d3/files/adapter_model.safetensors \
  -H "Authorization: Bearer $GRIDLINE_TOKEN" -o adapter_model.safetensors
```

## Merged weights

Download merged weights: `POST /v1/gridline/adapters/{model}/merged` starts a merge of your adapter into its base; `GET` the same path for its status and file list; `GET …/merged/files/{path}` downloads each file.

Merged weights: download your fine-tuned model as full safetensors at no charge. Each export is kept for 7 days.

- The merge adds the adapter to the base's bf16 weights (W + B·A × lora_alpha / r, rounded once to bf16, as peft's `merge_and_unload` does) and keeps the base's shard layout. Status goes `queued`, `running`, then `succeeded` or `failed`; after 7 days it reads `expired`, and a new `POST` merges again.
- An export holds the base's files with the adapted shards changed, the base's licence (`LICENSE`; for Llama 3.1 8B also `USE_POLICY.md`, its Acceptable Use Policy), a `NOTICE` that says what was changed, and `gridline_merge.json`, which lists the modified files and every file's SHA-256. Each download is a redirect that carries the file's SHA-256 in `x-gridline-sha256`.
- Built with Llama: a merged `llama-3.1-8b-instruct` export is a copy of Meta's Llama Materials. Its `NOTICE` carries Meta's attribution notice, and if you distribute it, the [Llama 3.1 Community License](https://www.llama.com/llama3_1/license/) applies to you, including its rule that the name of a model you distribute begins with "Llama".
- 1 merge at a time and 10 a day per account; at most 4 exports and 64 GB kept per account. `DELETE` the same path deletes the export and cancels a pending merge.

A `POST` the adapter cannot have answers `400 invalid_request`, "This adapter cannot be merged (`code`).":

| Code | Meaning |
| --- | --- |
| `merge_base_unknown` | The adapter's base is not a fine-tuning base model. |
| `merge_base_revision` | The adapter was trained on another revision of its base than the one Gridline pins. |
| `merge_licence_unreviewed` | The base's licence has not been reviewed for handing out merged copies yet. |
| `merge_tied_lm_head` | The adapter trains lm_head on a base whose lm_head shares the embedding weights (the Qwen2.5 0.5B and 1.5B bases), so it cannot be merged. |

A failed merge carries `error.code` on the merged-weights object. Nothing is charged for a merge, failed or not; `POST` again to retry:

| Code | Meaning |
| --- | --- |
| `merge_adapter_changed` | The adapter's files changed after the merge was requested. |
| `merge_adapter_unavailable` | The adapter's files could not be read. |
| `merge_adapter_config` | adapter_config.json is not a LoRA configuration for this base. |
| `merge_adapter_header` | The adapter's safetensors header could not be read. |
| `merge_adapter_tensor_name` | An adapter tensor is not a lora_A or lora_B weight of a known module. |
| `merge_adapter_dtype` | An adapter weight is not F32, BF16 or F16. |
| `merge_adapter_pair` | An adapted module is missing its lora_A or lora_B weight. |
| `merge_adapter_shape` | An adapter weight's shape does not match its module or rank. |
| `merge_rank` | The adapter's rank is out of range. |
| `merge_alpha` | The adapter's lora_alpha is out of range. |
| `merge_scale` | lora_alpha / r is not a finite number. |
| `merge_base_tensor_missing` | An adapted module has no weight in the base. |
| `merge_base_tensor_duplicate` | A base weight appears in more than one shard. |
| `merge_base_dtype` | An adapted base weight is not BF16. |
| `merge_base_header` | A base shard's safetensors header could not be read. |
| `merge_base_digest` | A base file's bytes did not match the pinned SHA-256. |
| `merge_base_unavailable` | Hugging Face did not serve a base file at the pinned revision. |
| `merge_base_range` | Hugging Face answered a ranged read with the whole file. |
| `merge_base_short` | Hugging Face returned fewer bytes than asked for. |
| `merge_base_window` | The merge machine's read-window settings for a base file were invalid. |
| `merge_base_path` | A base file name was refused. |
| `merge_licence_digest` | A licence file's bytes were not the reviewed ones. |
| `merge_factor_unknown` | An adapter weight named in the plan is not in the adapter file. |
| `merge_factor_size` | An adapter weight's bytes did not match its header. |
| `merge_factor_dtype` | An adapter weight could not be decoded. |
| `merge_factor_shape` | An adapter weight's shape did not match the base weight it adapts. |
| `merge_source_header` | A base shard's header changed while it was read. |
| `merge_source_short` | A base shard ended before its header said. |
| `merge_source_long` | A base shard was longer than its header said. |
| `merge_target_tensor` | Two adapted base weights overlap in one shard. |
| `merge_output_size` | A merged shard's size differs from the base shard's. |
| `merge_timeout` | The merge did not finish within 15 minutes and was stopped. |
| `merge_attempts_exhausted` | The merge was tried 3 times. |
| `merge_machine_memory` | The base is too large for the merge machine. |
| `merge_machine_unavailable` | No merge machine could be started. |
| `merge_machine_exited` | The merge machine stopped without a result. |
| `merge_machine_unset` | The merge machine was not configured. |
| `merge_spec_invalid` | The merge machine could not read its instructions. |
| `merge_credentials_unavailable` | Storage credentials for the merge could not be issued. |
| `merge_result_invalid` | The merge machine's files did not match the plan. |
| `merge_readback_mismatch` | A stored file's bytes did not match when Gridline read them back, so the export was deleted. |
| `merge_readback_unavailable` | Gridline could not read the stored files back in time to check them. |
| `merge_weights_quota` | Your account already holds 4 exports or 64 GB of them. Delete one and POST again. |
| `merge_weights_gone` | The export was deleted before the merge finished. |
| `merge_weights_prefix` | The export's storage location was refused. |
| `merge_failed` | The merge failed for another reason. |

## Limits

- Supervised fine-tuning on chat data, LoRA and QLoRA adapters only.
- Full fine-tunes are not offered; merged weights are a download of your adapter merged into its base ([Merged weights](https://gridlinegpu.com/docs/fine-tuning#merged)).
- A job must finish within 2 hours (v1); create refuses one estimated longer. The estimate uses a conservative training speed for each base, at or below what we measured, so a job fits only if its training tokens, counting every epoch, are at most about: 16,500,000 (qwen2.5-0.5b-instruct), 5,940,000 (qwen2.5-1.5b-instruct), 5,280,000 (qwen2.5-7b-instruct, LoRA; 1,980,000 with QLoRA), 4,950,000 (mistral-7b-instruct-v0.3, LoRA; 1,848,000 with QLoRA), 4,620,000 (qwen3-8b, LoRA; 1,650,000 with QLoRA), 4,752,000 (llama-3.1-8b-instruct, LoRA; 1,782,000 with QLoRA), 858,000 (gpt-oss-20b, LoRA), 2,640,000 (qwen3.5-9b, LoRA). A bit less fits once evaluation is counted; the refusal says exactly how many.
- Since 10 November 2026, create refuses larger jobs than before, and quotes show longer estimated times and higher estimated costs. The old estimate assumed faster training than our GPUs deliver, so jobs it admitted could run past the limit and fail.
- One job trains at a time per account. New accounts can have 1 open at once, other accounts 3, unless Gridline has set a different limit; a refused create gives yours in error.limit.
- At most 100 models per account, including imports. Each open job reserves one slot and space for the largest accepted adapter before training starts. After it ends, only its stored adapter counts. Wait for a job to finish, cancel it before registration, or delete a model to make room.

## Your data

- Training and validation files are stored under your account. Only your account can read them, use them in a job, or see the jobs and models made from them; any other account is answered not found.
- Rows are never logged. A refused file names the line and a fixed error code, never its content. Job events carry counts and losses, never rows.
- A fine-tune file is kept for 30 days after upload, and you can delete it sooner. While a job that uses it is running, it cannot be deleted and does not expire.
- Fine-tuning jobs and their events are kept for 180 days after they end.
- A fine-tuned model is kept until you delete it. Deleting it makes it unusable at once and requests deletion of its adapter files from storage; a batch already running keeps the copy it staged until it ends.

Your training and validation files are used only to train your fine-tuned model. We don't use them to train any other model, and we don't sell or share them, except with the infrastructure providers that run the service: RunPod and Google Cloud (the GPUs that train your model), Cloudflare R2 and Supabase (storage) and Fly.io (our API servers), which process them only to provide it. Uploaded files are deleted 30 days after upload, or sooner if you delete them. Copies made for a training run are deleted 7 days after it ends. Fine-tuning job records are deleted 180 days after the job ends. Your fine-tuned model is yours: you can download or delete it at any time.

## Costs

Fine-tuning and batch jobs are paid for from your prepaid credits. A fine-tuning job is charged for its training tokens times the number of epochs, at its base model's rate, and at least the per-job minimum. It is quoted when you create it, before it starts: creating the job starts it, and the same response carries the quote, with those tokens, the minimum and the most the job can cost. When the job succeeds the charge is deducted from your credits, and it is never more than the quote. Credit is checked when the job starts; a job your credit cannot fund fails with `insufficient_quota` before anything runs.

What a credit is worth, the credit packs and each model's rates are on the [pricing page](https://gridlinegpu.com/pricing).

---

Source: https://gridlinegpu.com/docs/fine-tuning
