Fine-tuning Jobs
aimock implements OpenAI's fine-tuning job surface in memory, so an SDK that creates a
job, polls it and reads its events gets a complete lifecycle without touching
api.openai.com. Progression is deterministic and driven by retrieves, not by
wall-clock time, so tests never sleep.
Endpoints
| Method | Path | Purpose |
|---|---|---|
POST |
/v1/fine_tuning/jobs |
Create a job from training_file + model |
GET |
/v1/fine_tuning/jobs |
List jobs, newest first (limit, after) |
GET |
/v1/fine_tuning/jobs/{id} |
Retrieve, advancing the job one step |
POST |
/v1/fine_tuning/jobs/{id}/cancel |
Cancel a non-terminal job |
GET |
/v1/fine_tuning/jobs/{id}/events |
Read the job's event log (limit, after) |
Deterministic Progression
A new job starts at validating_files. Each retrieve advances it one step —
first to queued, then to running, then to
succeeded with a fine_tuned_model name and a
finished_at stamp. Terminal jobs stop advancing.
create -> validating_files
retrieve -> queued
retrieve -> running
retrieve -> succeeded (fine_tuned_model: "ft:<model>:aimock:<job-id-tail>")
with a create-time suffix, one more segment:
"ft:<model>:aimock:<suffix>:<job-id-tail>"
cancel -> cancelled (any non-terminal job, with a finished_at stamp;
400 once terminal: succeeded, cancelled or failed)
The Job Object
Every job response carries all fifteen fields the OpenAI API marks required on
fine_tuning.job — id, object,
created_at, error, fine_tuned_model,
finished_at, hyperparameters, model,
organization_id, result_files, seed,
status, trained_tokens, training_file and
validation_file. The nullable ones are emitted as null until the
lifecycle fills them in, so typed SDK clients never see a missing key.
seed is derived from the job id and is stable across reads when the create
body sends none — a seed you do send is echoed back unchanged —
organization_id is the fixed mock value org-aimock, and
result_files and trained_tokens stay empty and
null until the job reaches succeeded. The three optional members
— integrations, method and metadata —
are present on the job exactly when the create body carried a value for them, and absent
otherwise, because none of them is required and emitting null would be a
field the vendor never sends. Sending one of them as an explicit null is
legal and means the same thing as omitting it: the job comes back with no such key at all,
rather than with a null one.
Create Parameters
All nine properties CreateFineTuningJobRequest declares are read, and only
those nine: an unrecognized top-level parameter is rejected with 400 listing
the nine, rather than accepted and dropped.
| Parameter | What the mock does with it |
|---|---|
model, training_file |
Required non-empty strings; echoed on the job |
validation_file |
Non-empty string or null; echoed on the job |
hyperparameters |
The deprecated top-level object: n_epochs, batch_size,
learning_rate_multiplier only, each range-checked and echoed
|
suffix |
A string of 1–64 Unicode code points (not UTF-16 units, so an astral character
such as an emoji counts once) or null; becomes a segment of
fine_tuned_model once the job succeeds, and is never echoed as a field
(the vendor’s fine_tuning.job has no suffix)
|
seed |
An integer 0–2147483647, honoured verbatim;
null and omission are both legal and mean “generate one”,
in which case the job reports a seed derived from its id, stable across reads
|
method |
type is required and one of supervised, dpo,
reinforcement; the chosen type’s own
hyperparameters table is enforced and a
reinforcement method must carry a grader, which is passed
through as the JSON object you sent. A configuration object belonging to a type
other than the one in method.type is rejected with 400
naming it, rather than validated, echoed or ignored. Echoed on the job
|
metadata |
At most 16 string pairs, keys ≤ 64 and values ≤ 512 Unicode code points (the
same unit suffix is counted in); echoed on the job
|
integrations |
wandb entries with a required wandb.project; echoed on the
job
|
Job Lifecycle
openai SDK
ts
const mock = new LLMock({ port: 0 });
await mock.start();
const openai = new OpenAI({ baseURL: `${mock.url}/v1`, apiKey: "test" });
const job = await openai.fineTuning.jobs.create({
training_file: "file-train",
model: "gpt-4o-mini",
});
expect(job.status).toBe("validating_files");
await openai.fineTuning.jobs.retrieve(job.id); // queued
await openai.fineTuning.jobs.retrieve(job.id); // running
const done = await openai.fineTuning.jobs.retrieve(job.id);
expect(done.status).toBe("succeeded");
expect(done.fine_tuned_model).toMatch(/^ft:gpt-4o-mini/);
Events
Events are an append-only log: creating the job, each status transition a retrieve drives,
and a cancel each append exactly one event, so the list is what actually happened to that
job rather than a rendering of its current status. Each event carries the full
fine_tuning.job.event shape. An event's id is ftevent- plus the
job id's suffix (the job id with its own ftjob- prefix stripped) plus the
event's position in that log, which never renumbers, so an id denotes the same event
across later reads and later transitions and works as an after cursor. Read
the id off the event rather than rebuilding it: an after spelled
`${job.id}-0` matches no event and is answered 400. The page
comes back newest-first, and every event is level: "info" — this mock
has no failure path, so it never emits a warn or error event.
{
"object": "list",
"data": [
{
"id": "ftevent-<job-suffix>-0",
"object": "fine_tuning.job.event",
"created_at": 1700000000,
"level": "info",
"message": "Job created, validating training file",
"type": "message"
}
],
"has_more": false
}
Pagination
Both list endpoints are cursor pages ordered newest-first: limit defaults to
20 and must be written as plain decimal digits naming an integer from
1 to 100, after resumes from an id, and
has_more reports whether anything was left behind rather than being a
constant. That is what the OpenAI SDK's CursorPage reads when you iterate
results. Any other spelling is rejected with 400 quoting the raw query text
you sent, and that includes every spelling Number() would have accepted,
whether it would have landed on a different integer (0x10 as 16,
1e2 as 100) or on the very one you meant (%2B5,
20.0, a percent-encoded whitespace-padded %2020%20). A literal
+ must be percent-encoded, since a bare + in a query decodes to
a space. An after that matches no item is rejected the same way — a
stale cursor that quietly returned page one would make CursorPage iterate
forever. Sending limit or after more than once on one request is
also a 400, naming the parameter and the number of times it appeared: the
spec types each as a single scalar and gives no combining rule, so taking the first would
launder the rest.
Only the 20 default is the vendor’s: openai-openapi v2.3.0
types limit as an integer defaulting to 20 on both endpoints.
The upper bound of 100, the refusal of non-decimal spellings of an in-range
integer, the 400 on an after that matches nothing, the
400 on a repeated limit or after and the
newest-first order are this mock’s own choices: the same spec declares no maximum,
says nothing about a cursor that misses or a repeated parameter, and accepts no
order parameter on either endpoint. So are the create-body rejections that
refuse what the spec merely fails to forbid: an unrecognized top-level parameter; an
unrecognized member of hyperparameters, of method, of a
method variant, of an integrations entry or of its
wandb object; an integrations list longer than the five the job
schema declares; a configuration for a method type other than the one in
method.type; and the empty string as a file id, on
training_file as on validation_file. The spec marks none of
those objects additionalProperties: false, sets no minLength on
a file id and says nothing about a stray variant, and a mock that answered
200 having dropped what you wrote would let the bug through to the real API.
Every other rejection in the list below enforces a bound the spec itself declares, and the
hyperparameters ranges are the vendor’s, quoted from that spec.
// The store is process-wide, so a job any earlier test left behind would be
// on this page too. Reset first, then create exactly the two jobs counted below.
// reset() is synchronous and returns the instance; there is nothing to await.
mock.reset();
await openai.fineTuning.jobs.create({ training_file: "file-train", model: "gpt-4o-mini" });
await openai.fineTuning.jobs.create({ training_file: "file-train-2", model: "gpt-4o-mini" });
const page = await fetch(`${mock.url}/v1/fine_tuning/jobs?limit=1`).then((r) => r.json());
expect(page.has_more).toBe(true);
const next = await fetch(
`${mock.url}/v1/fine_tuning/jobs?after=${page.data[0].id}`,
).then((r) => r.json());
expect(next.has_more).toBe(false);
Validation
- A non-object request body is rejected with
400 - Malformed JSON is rejected with
400 -
A
training_fileormodelthat is missing, not a string, or the empty string is rejected with400 -
A
hyperparametersvalue that is not a JSON object is rejected with400,nullincluded: the spec types the fieldtype: objectand, unlike its siblingssuffix,validation_fileandintegrations, never marks itnullable -
A
hyperparametersmember outside the range the spec declares for it, or that is neither a number nor the string"auto", is rejected with400naming the field: the mock never silently drops it.n_epochsmust be an integer from1to50,batch_sizean integer from1to256, andlearning_rate_multipliera number strictly greater than0 -
A
validation_filethat is present but neither a non-empty string nornullis rejected with400; omitting it or sendingnullis accepted and the job reportsvalidation_file: null -
A
hyperparametersmember the object does not declare —betais the one people reach for — is rejected with400naming it and listing the three members the object does take.betais legal, but undermethod.dpo.hyperparameters, which is the only placeFineTuneDPOHyperparametersdeclares it; sending it undermethod.supervised.hyperparametersis the same400 -
A top-level create parameter outside the nine in the table above is rejected with
400listing the nine -
A
suffixthat is not a string of 1–64 code points is rejected with400;nullmeans “none”. Aseedthat is not an integer in0–2147483647andmetadataoutside the 16-pair / 64-code-point-key / 512-code-point-value bounds are rejected the same way -
integrationsis checked entry by entry: a list of more than five entries, an entry member other thantypeandwandb, awandbmember other thanproject,name,entityandtags, and aprojectthat is missing or not a non-empty string are each a400. The five-entry cap and the two unknown-member rejections are this mock’s own; the rest is the shape the spec requires -
On either list endpoint, a
limitthat is not plain decimal digits naming an integer in1–100, or anafterthat matches no item in the list, is rejected with400naming the parameter; so is alimitoraftergiven more than once - An unknown job id returns
404on retrieve, cancel and events -
Cancelling a job that is already terminal —
succeeded,cancelledorfailed— returns400naming the status it is stuck in. Cancelling a non-terminal job returns200, moves it tocancelledand stampsfinished_at
Not Implemented
aimock covers the five endpoints in the table above and nothing else on this surface. The
openai SDK 4.104.0 also exposes jobs.pause(),
jobs.resume(), jobs.checkpoints.list(),
checkpoints.permissions.* and alpha.graders.*; aimock has no
handler for POST /v1/fine_tuning/jobs/{id}/pause,
POST /v1/fine_tuning/jobs/{id}/resume,
GET /v1/fine_tuning/jobs/{id}/checkpoints,
/v1/fine_tuning/checkpoints/{ckpt}/permissions or the
/v1/fine_tuning/alpha/graders/* routes, so those paths fall through to the
server’s generic
404 {"error":{"message":"Not found","type":"not_found"}} rather than to this
surface’s own No such fine-tuning job error. Those 404s are still
counted, under the normalized path labels described below, so an SDK that
polls jobs.pause() cannot inflate metric cardinality. A job also never
reaches failed: nothing in the mock drives that status, so it exists in the
type and in the cancel guard but is never produced.
State & Journal
Jobs live in memory for the life of the process, not of an individual
LLMock: every instance in one process shares a single store, so a job created
against one instance is visible from another, and new LLMock() does not start
empty. Clearing the store is what gives a test a clean slate, and
POST /__aimock/reset, LLMock.reset() and
clearFineTuningStore() all do it. Every request the handler serves is
recorded exactly once in the journal with service: "fine-tuning" — including
chaos-faulted requests — and a create whose body parsed into a JSON object carries that
body, whether it went on to succeed or to be rejected, on any of the rejections above.
Three kinds of entry record body: null: the GET and cancel
routes, a create whose body was not a JSON object at all, and any request the chaos gate
faulted. The gate does not roll before the body is read. On create and cancel the server
reads the body first, then hands off to the handler, whose first act is the chaos roll,
and only a create that survives the roll goes on to parse what was read; a faulted
request’s entry is written by the gate itself with a fixed body: null,
so a create carrying a perfectly good JSON body still journals null when
chaos fires, and its response names the chaosAction that did. A
cancel journals its outcome as response.status alone —
200 when the job moved to cancelled, 404 for an
unknown id, 400 for a job already terminal — with
body: null and no reason field of any kind; the human-readable reason,
Job cancelled by user, is the event the cancel appends to the job’s own
log and is read back from /events, not from the journal. The cancel route
takes no payload, but it is not bodyless: it reads and discards whatever you send, so that
readBody's 10 MB ceiling applies to it as it does to create. Those two are
also the only requests that are not journaled at all: a create or a cancel whose body
could not be read in the first place — past the body-size cap, or a broken socket — is
answered 500 before the fine-tuning handler runs, exactly as on every other
aimock route. An entry's path is the raw request URL, query string included,
so ?limit= and ?testId= survive into the journal and stay
filterable. “Query string” here means everything after the first
?: an HTTP request target has no fragment component, so a literal
# is part of the query rather than a cut-off point —
?limit=5#frag is a 400 quoting '5#frag', not a page
of five. Select entries by service rather than by exact
path equality. So the surface is chaos-gated like the other job endpoints and
still fully inspectable — see Chaos Testing.
const entries = mock.getRequests();
const ftRequests = entries.filter((e) => e.service === "fine-tuning");
See Control API for reset and journal, and
Prometheus Metrics: everything under
/v1/fine_tuning/ is normalized, so nothing a caller controls can mint a
label. The label set is fifteen shapes — /v1/fine_tuning/jobs,
/v1/fine_tuning/jobs/{id}, one per named job sub-resource
(/cancel, /events, /pause, /resume,
/checkpoints) with any other collapsed to
/v1/fine_tuning/jobs/{id}/{action}; the id-free grader routes
/v1/fine_tuning/alpha/graders/run and
/v1/fine_tuning/alpha/graders/validate kept verbatim;
/v1/fine_tuning/checkpoints/{ckpt},
/v1/fine_tuning/checkpoints/{ckpt}/permissions,
/v1/fine_tuning/checkpoints/{ckpt}/permissions/{id} and
/v1/fine_tuning/checkpoints/{ckpt}/{action}; and a single
/v1/fine_tuning/{other} catch-all for any path none of those match. The
routes this mock does not implement are normalized too, so an SDK that polls
jobs.pause(), or a typo at an unknown depth, cannot inflate cardinality.