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.

Status ladder text
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.jobid, 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 02147483647, 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

Driving the lifecycle with the 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.

Events response json
{
  "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.

Paging a job list ts
// 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

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.

Journal entries ts
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.