Fine-tune with a Hosted API
By Syncfusion®, Inc. · Last updated
Upload training files, start a supervised fine-tuning job over a provider's REST API, poll it, read the loss from its events and call the new model ID, in TypeScript or C#.
Fine-tune with a Hosted API
The team has clean training files and a ship rule. They do not have a GPU, and nobody wants to run one.
Most model providers will train for you. You upload a file, start a job, and get back a new model name. Your app then calls that name like any other model.
The recorded job above trained on 1,512,000 tokens over 3 epochs and returned ft:gpt-4o-mini-2024-07-18:example-org:tickets-v2:Tt9xQ2b1.
Simple lesson: a hosted fine-tune is four API calls and a loop that waits. The hard parts are still the data and the eval.
A Real-World Example
Think about sending a suit to a tailor instead of sewing it yourself.
You bring the measurements and the cloth. The tailor does the work in their shop, on their machines, and hands back the finished suit with a ticket number.
You still have to check the fit. And if the tailor keeps your cloth, you need to be happy with that.
Simple lesson: hosted training saves you the machines, not the measuring or the fitting.
What You Will Learn
In this lesson, you will learn:
- The four calls of a hosted fine-tune: upload, create, poll, use.
- How to write a poller that waits the right amount and stops at the right time.
- How to read the loss from the job's events and decide on the next run.
- How to estimate the training tokens before you upload.
- When hosted is the wrong choice, and what changes in your code if you switch.
Before you start
- Hosted fine-tune = the provider trains a copy of one of its models on your file, on its own GPUs, and serves it under a new model name.
- The calls follow OpenAI's fine-tuning REST API. Azure OpenAI uses the same shapes under its own endpoint and
api-version. Other providers have similar steps with different names. - Field names, supported base models and prices change. Check your provider's current API reference before you run this for real.
TL;DR
POST /v1/fileswithpurpose=fine-tune, once for train and once for validation.POST /v1/fine_tuning/jobswith the base model, both file IDs andmethod: { type: "supervised" }.GET /v1/fine_tuning/jobs/{id}until the status issucceeded,failedorcancelled.- Read
/eventsfor the loss. Write the model ID into your manifest. Run the eval.
Build the Request from Config
Every setting comes from ticket-tuner.config.json, so a run can be repeated. Hyperparameters go under method. The older top-level hyperparameters field is deprecated.
Build the create-job request
// Build the create-job request from your config, the way the repo's createJob() does.
const config = { baseModel: "gpt-4o-mini-2024-07-18", suffix: "tickets-v2", nEpochs: 3, seed: 42 };
const datasetSha256 = "9c1e0d7a52b4f6e8a1c3d5f7b9e0a2c4d6f8b1a3c5e7d9f0b2a4c6e8d0f1a3b5";
const body = {
model: config.baseModel,
training_file: "file-tt-train-0001", // from POST /v1/files
validation_file: "file-tt-val-0001",
suffix: config.suffix, // becomes part of the model name
seed: config.seed, // same seed, same shuffling
metadata: { dataset_sha256: datasetSha256.slice(0, 16) },
method: { type: "supervised", supervised: { hyperparameters: { n_epochs: config.nEpochs } } },
};
console.log("POST /v1/fine_tuning/jobs");
console.log(JSON.stringify(body, null, 2));
Build the create-job request
using System.Text.Encodings.Web;
using System.Text.Json;
// Build the create-job request from your config, the way the repo's CreateJobAsync() does.
var config = new { BaseModel = "gpt-4o-mini-2024-07-18", Suffix = "tickets-v2", NEpochs = 3, Seed = 42 };
var datasetSha256 = "9c1e0d7a52b4f6e8a1c3d5f7b9e0a2c4d6f8b1a3c5e7d9f0b2a4c6e8d0f1a3b5";
var body = new Dictionary<string, object>
{
["model"] = config.BaseModel,
["training_file"] = "file-tt-train-0001", // from POST /v1/files
["validation_file"] = "file-tt-val-0001",
["suffix"] = config.Suffix, // becomes part of the model name
["seed"] = config.Seed, // same seed, same shuffling
["metadata"] = new { dataset_sha256 = datasetSha256[..16] },
["method"] = new { type = "supervised", supervised = new { hyperparameters = new { n_epochs = config.NEpochs } } },
};
Console.WriteLine("POST /v1/fine_tuning/jobs");
Console.WriteLine(JsonSerializer.Serialize(body, new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }));
Expected output:
POST /v1/fine_tuning/jobs
{
"model": "gpt-4o-mini-2024-07-18",
"training_file": "file-tt-train-0001",
"validation_file": "file-tt-val-0001",
"suffix": "tickets-v2",
"seed": 42,
"metadata": {
"dataset_sha256": "9c1e0d7a52b4f6e8"
},
"method": {
"type": "supervised",
"supervised": {
"hyperparameters": {
"n_epochs": 3
}
}
}
}
The metadata ties the job to the exact training file. When someone asks which data made this model, the answer is on the job.
Poll Until It Ends
A job goes through validating_files, queued and running, then ends. Your poller waits longer as the job gets longer, and stops for good when it ends.
Poll a job until it ends
// Poll a job until it ends. These are the statuses the recorded job went through.
type Job = { status: string; fine_tuned_model: string | null };
const polls: Job[] = [
{ status: "validating_files", fine_tuned_model: null },
{ status: "queued", fine_tuned_model: null },
{ status: "running", fine_tuned_model: null },
{ status: "running", fine_tuned_model: null },
{ status: "succeeded", fine_tuned_model: "ft:gpt-4o-mini-2024-07-18:example-org:tickets-v2:Tt9xQ2b1" },
];
function nextAction(job: Job): { action: "wait" | "done" | "stop"; waitMs: number } {
if (job.status === "validating_files") return { action: "wait", waitMs: 15000 };
if (job.status === "queued") return { action: "wait", waitMs: 30000 };
if (job.status === "running") return { action: "wait", waitMs: 60000 };
if (job.status === "succeeded" && job.fine_tuned_model) return { action: "done", waitMs: 0 };
return { action: "stop", waitMs: 0 };
}
let waited = 0;
for (const job of polls) {
const next = nextAction(job);
console.log(`${job.status.padEnd(17)} -> ${next.action}${next.waitMs ? ` (poll again in ${next.waitMs / 1000} s)` : ""}`);
waited += next.waitMs;
if (next.action !== "wait") {
console.log(next.action === "done" ? `model: ${job.fine_tuned_model}` : "job ended without a model");
break;
}
}
console.log(`time spent waiting between polls: ${waited / 1000} s`);
Poll a job until it ends
// Poll a job until it ends. These are the statuses the recorded job went through.
var polls = new List<Job>
{
new("validating_files", null),
new("queued", null),
new("running", null),
new("running", null),
new("succeeded", "ft:gpt-4o-mini-2024-07-18:example-org:tickets-v2:Tt9xQ2b1"),
};
(string Action, int WaitMs) NextAction(Job job)
{
if (job.Status == "validating_files") return ("wait", 15000);
if (job.Status == "queued") return ("wait", 30000);
if (job.Status == "running") return ("wait", 60000);
if (job.Status == "succeeded" && job.FineTunedModel != null) return ("done", 0);
return ("stop", 0);
}
var waited = 0;
foreach (var job in polls)
{
var next = NextAction(job);
Console.WriteLine($"{job.Status,-17} -> {next.Action}{(next.WaitMs > 0 ? $" (poll again in {next.WaitMs / 1000} s)" : "")}");
waited += next.WaitMs;
if (next.Action != "wait")
{
Console.WriteLine(next.Action == "done" ? $"model: {job.FineTunedModel}" : "job ended without a model");
break;
}
}
Console.WriteLine($"time spent waiting between polls: {waited / 1000} s");
record Job(string Status, string? FineTunedModel);
Expected output:
validating_files -> wait (poll again in 15 s)
queued -> wait (poll again in 30 s)
running -> wait (poll again in 60 s)
running -> wait (poll again in 60 s)
succeeded -> done
model: ft:gpt-4o-mini-2024-07-18:example-org:tickets-v2:Tt9xQ2b1
time spent waiting between polls: 165 s
Run the poller as a background job, not in a web request.
Queue time depends on the provider and can be hours. Start the job from CI or a worker, store the job ID, and let a scheduled task finish the work: read the result, write the manifest, start the eval.
Read the Loss from the Events
The provider does not hand you a chart. It reports training and validation loss as events, newest first.
Find the best point in the job's events
// The events endpoint lists newest first. Sort by step, then find the best point.
const events = [
{ step: 105, train: 0.364, valid: 0.544 }, { step: 100, train: 0.369, valid: 0.527 },
{ step: 90, train: 0.377, valid: 0.497 }, { step: 80, train: 0.392, valid: 0.472 },
{ step: 70, train: 0.416, valid: 0.456 }, { step: 60, train: 0.455, valid: 0.495 },
{ step: 50, train: 0.515, valid: 0.555 }, { step: 40, train: 0.61, valid: 0.65 },
];
const curve = [...events].sort((a, b) => a.step - b.step);
let best = curve[0];
for (const p of curve) if (p.valid < best.valid) best = p;
for (const p of curve) console.log(`step ${String(p.step).padStart(3)} train ${p.train.toFixed(3)} valid ${p.valid.toFixed(3)}${p === best ? " <- lowest" : ""}`);
const last = curve[curve.length - 1];
const totalSteps = 105, epochs = 3;
const bestEpoch = Math.ceil((best.step / totalSteps) * epochs);
console.log(`validation loss rose from ${best.valid} to ${last.valid} after step ${best.step}`);
console.log(`next run: n_epochs ${bestEpoch} instead of ${epochs}, then compare both on the eval set`);
Find the best point in the job's events
// The events endpoint lists newest first. Sort by step, then find the best point.
var events = new List<Point>
{
new(105, 0.364, 0.544), new(100, 0.369, 0.527),
new(90, 0.377, 0.497), new(80, 0.392, 0.472),
new(70, 0.416, 0.456), new(60, 0.455, 0.495),
new(50, 0.515, 0.555), new(40, 0.61, 0.65),
};
var curve = events.OrderBy(p => p.Step).ToList();
var best = curve[0];
foreach (var p in curve) if (p.Valid < best.Valid) best = p;
foreach (var p in curve) Console.WriteLine($"step {p.Step,3} train {p.Train:F3} valid {p.Valid:F3}{(p == best ? " <- lowest" : "")}");
var last = curve[^1];
int totalSteps = 105, epochs = 3;
var bestEpoch = (int)Math.Ceiling((double)best.Step / totalSteps * epochs);
Console.WriteLine($"validation loss rose from {best.Valid} to {last.Valid} after step {best.Step}");
Console.WriteLine($"next run: n_epochs {bestEpoch} instead of {epochs}, then compare both on the eval set");
record Point(int Step, double Train, double Valid);
Expected output:
step 40 train 0.610 valid 0.650
step 50 train 0.515 valid 0.555
step 60 train 0.455 valid 0.495
step 70 train 0.416 valid 0.456 <- lowest
step 80 train 0.392 valid 0.472
step 90 train 0.377 valid 0.497
step 100 train 0.369 valid 0.527
step 105 train 0.364 valid 0.544
validation loss rose from 0.456 to 0.544 after step 70
next run: n_epochs 2 instead of 3, then compare both on the eval set
Validation loss was lowest at step 70 (0.456) and rose to 0.544 by the end. The last epoch made the model worse on data it had not seen. Most hosted APIs keep only the final model, so the fix is another run with fewer epochs, judged on the eval set.
Checkpoints on hosted jobs
- Some providers also keep a few checkpoints from earlier in the run and let you call them by name.
- If yours does, compare the best checkpoint with the final model on the eval set before re-running.
- If it does not, set
n_epochsfrom what the events showed and run again.
Estimate the Tokens Before You Upload
Hosted training is billed by tokens processed: the file's tokens multiplied by the epochs. Count first.
Estimate the trained tokens
// Roughly how many tokens will the job train on? Count before you upload.
// Hosted providers bill training by tokens processed: the file's tokens × epochs.
const trainingLines = [
{ chars: 412 }, { chars: 388 }, { chars: 455 }, { chars: 401 }, { chars: 1960 },
];
const linesInFile = 560; // wc -l train.jsonl
const epochs = 3;
const pricePerMillion = 3.0; // illustrative only: use your provider's current price
const avgChars = trainingLines.reduce((s, l) => s + l.chars, 0) / trainingLines.length;
const tokensPerLine = Math.ceil(avgChars / 4); // about 4 characters per token in English
const fileTokens = tokensPerLine * linesInFile;
const trained = fileTokens * epochs;
console.log(`about ${tokensPerLine} tokens per line, ${fileTokens.toLocaleString("en-US")} in the file`);
console.log(`× ${epochs} epochs = ${trained.toLocaleString("en-US")} trained tokens`);
console.log(`at $${pricePerMillion.toFixed(2)} per million: about $${((trained / 1e6) * pricePerMillion).toFixed(2)} per run`);
Estimate the trained tokens
// Roughly how many tokens will the job train on? Count before you upload.
// Hosted providers bill training by tokens processed: the file's tokens × epochs.
int[] lineChars = [412, 388, 455, 401, 1960];
var linesInFile = 560; // wc -l train.jsonl
var epochs = 3;
var pricePerMillion = 3.0; // illustrative only: use your provider's current price
var avgChars = lineChars.Average();
var tokensPerLine = (int)Math.Ceiling(avgChars / 4); // about 4 characters per token in English
var fileTokens = tokensPerLine * linesInFile;
var trained = fileTokens * epochs;
Console.WriteLine($"about {tokensPerLine} tokens per line, {fileTokens:N0} in the file");
Console.WriteLine($"× {epochs} epochs = {trained:N0} trained tokens");
Console.WriteLine($"at ${pricePerMillion:F2} per million: about ${trained / 1e6 * pricePerMillion:F2} per run");
Expected output:
about 181 tokens per line, 101,360 in the file
× 3 epochs = 304,080 trained tokens
at $3.00 per million: about $0.91 per run
GPU time is rarely the big cost. A failed job from a bad file wastes an hour of someone's day, which is why the data check runs before the upload.
When a Job Fails
A failed job says why. The recorded one failed with invalid_training_file: line 37 did not end with an assistant message.
| What you see | Where to look | Fix |
|---|---|---|
invalid_training_file | One line of the file | Run the data check, fix the line, upload again |
| Queued for a long time | Nothing is wrong | Keep polling, less often |
| HTTP 401 in CI only | The secret store | Add the key to CI secrets |
| Validation loss rises late | The epochs | One epoch fewer, compare on the eval |
| Model works in the playground, 404 in the app | The app's config | Use the exact model ID from the manifest |
Other Providers
The four calls are the same idea everywhere. The names change.
| Provider | Start a job | What you call afterwards |
|---|---|---|
| OpenAI | POST /v1/fine_tuning/jobs | The ft: model ID |
| Azure OpenAI | The same jobs API on your Azure resource, with an api-version | A deployment of the new model, which you create |
| Amazon Bedrock | A model customization job, with the data in S3 | The custom model, through the throughput option your model supports |
| Google Vertex AI | A supervised tuning job for a Gemini model | The tuned model's endpoint |
| Self-hosted | The starter kit's train_qlora.py on your GPU | Your vLLM server, with the adapter loaded |
Check each provider's current documentation for field names, supported base models and prices. The repo's client code only changes in apiBase, the model name and the auth header.
No API key? Measure the baseline anyway.
The Fine-tune Lab runs a model in your browser (turn it on in Settings). Its Live eval runs the course's eval tickets through the repo's baseline prompt and scores them like score.py, so you know the number a hosted fine-tune has to beat before you pay for one.
Hosted or Self-hosted?
| Hosted | Self-hosted LoRA (the starter kit) | |
|---|---|---|
| GPUs | The provider's | Yours, or rented |
| Where the training data goes | To the provider | Stays in your account |
| Base models | The provider's list | Any open model with a licence that fits |
| Serving | The provider's API | vLLM or similar, which you run |
| App code | Same client | Same client, different apiBase |
The client in the build-along repo works with both. Only the model name and the URL change.
What a lead decides before the first hosted job
- May this data leave our systems? Get the answer in writing. Redaction lowers the risk. It does not change who receives the data.
- Who owns the API key, and where does it live: a secret store, never a laptop or the repo.
- What happens when the provider retires the base model. The fine-tune goes with it. Put the date in the calendar.
- Who signs off the manifest before release.
Do's and Don'ts
- Do run the data check before every upload.
- Do set hyperparameters under
method, from a committed config file. - Do store the job ID, the model ID and the dataset hash together.
- Do read the events, not just the final status.
- Don't poll in a tight loop, or from a web request.
- Don't put the API key in code or in the notebook.
- Don't ship the model ID without running the eval.
Common mistakes
| Mistake | Wrong idea | Correct idea | What goes wrong |
|---|---|---|---|
| Upload without checking | "The provider validates it." | Check first, it is faster. | The job fails after waiting in the queue. |
| Polling forever | "Keep asking until it is done." | Stop on succeeded, failed or cancelled. | A failed job is polled all night. |
| Status only | "It succeeded, ship it." | Read the events and run the eval. | An overfitted model goes live. |
| Key in code | "It is just for testing." | Use a secret store. | The key leaks with the repo. |
| Model ID typed by hand | "I copied it from the dashboard." | Read it from the manifest. | The app calls the wrong model. |
Check Your Understanding
Exercises
What to Remember
- A hosted fine-tune is upload, create, poll, use.
- Settings come from config, epochs go under method, and metadata ties the job to the data.
- The poller waits longer as the job runs and stops when it ends.
- Read the events: the best point is often before the end.
- The app's client stays the same: only the model name changes.
Next: Reading a Training Run, how to tell a healthy run from a broken one by its curves.