Skip to content

Diagnostic LLM APIs

The fine-tuning job failed and error.code was never read

Someone kicked off the training run on a Thursday afternoon, watched the 200 come back, pasted the job id into the channel, and went home. The following Tuesday the deploy that was supposed to point at the new model is still pointing at the old one, which nobody notices because it works. Three weeks later a stakeholder asks how the fine-tune is performing and the honest answer turns out to be that it never trained. The job object has been sitting there the entire time with a status, an error code and the name of the field that was wrong, and nothing ever asked it.

Read-only key Python and Node.js Tests included
Paying with a card
Photo by Nathan Dumlao on Unsplash
The short answer

Ask the job, because the job knows. With a project key set to Read Only: GET /v1/fine_tuning/jobs?limit=100, paginating on after. Flag every object where status is "failed" and print its error.code, error.message and error.param — the last of which names the input that was rejected, usually training_file or validation_file.

Creation is asynchronous and creation succeeded. The job moves validating_files to queued to running and then to one of succeeded, failed or cancelled. Validation and training failures surface only on the job object, never as an HTTP error on the call that started it, so a caller that checks the status code of the create request has checked nothing that matters.

For narrative detail, GET /v1/fine_tuning/jobs/{id}/events returns the job's own log with a level and a message per entry. That is where the per-line validation complaints live when error.message is too terse to act on, and the script fetches it only for jobs that actually failed.

The other state worth catching is a job that never got a verdict at all: validating_files hours after it was created is not progress. It is treated here as a finding in its own right, because a job that is neither running nor failed is one nobody is watching.

This is the middle of three fine-tuning failures and it owns only the middle. A job that trained fine and never got any traffic, and the question of whether new jobs can be created at all, are both somebody else's note.

The problem in plain words

POST /v1/fine_tuning/jobs returns 200 as soon as the job is accepted, which is minutes or hours before anything is known about whether it will work. The Files API accepted the upload too, because it does not parse what you give it: any bytes are a valid fine-tune file at upload time, and the format is only checked later, inside the job.

So the failure lands in a place nobody is looking. status becomes failed, fine_tuned_model and trained_tokens stay null, and error fills in with a machine-readable code, a human-readable message, and a param naming the offending input. All of that is retrievable forever and none of it is pushed anywhere. There is no webhook in the default setup, no email, and no exception in the process that created the job, because that process exited successfully a week ago.

The causes are mundane and they repeat. A JSONL file with a trailing blank line, or a BOM, or a JSON array instead of one object per line. A row with no assistant message. A schema half-migrated between the legacy prompt/completion form and the chat form. Too few examples, or too many. And exceeded_quota, which is not a data problem at all and will not be fixed by editing the file, however long you stare at it.

The compounding cost is the deploy that quietly did not change. Everything downstream keeps using the previous model, which works, so the absence of the new one produces no symptom until somebody asks a question about a model that does not exist.

File uploadedbytes accepted,not parsedJob created,200 backid pasted into achannelValidationfails laterstatus failed,error setDeploy still onold modelso nothing looksbrokenAsked about itin a meetingthree weeks on
Every step succeeded on its own terms. The upload was accepted, the job was accepted, and the deploy did exactly nothing.

Why it happens

A 200 on create is a receipt, not a result. This is the single sentence the note exists to install. The create call tells you the job was accepted; the terminal status tells you what happened. Any pipeline that treats the first as the second will fail silently every time, and the fix is to poll the job to a terminal state in CI rather than to check a status code and move on.

error.param is the field that saves the afternoon. error.code tells you what class of thing went wrong and error.param tells you which input it went wrong on, which is the difference between rewriting the training set and rewriting the validation set. The script prints all three fields verbatim and in that order, because the message is often the only part that names the actual line.

The error codes are an open set, so the script does not pretend to know them all. A handful are documented and worth translating into an action — a malformed training file, a bad example count, an exhausted quota. Everything else is printed exactly as returned, with no guess attached. A diagnostic that invents an interpretation for a code it does not recognise is worse than one that prints the code, because it sends someone confidently in a direction the API never suggested.

A job stuck in validating_files is a finding, not a job in progress. Validation is quick. A job that has been validating for hours is not going to finish validating, and it occupies the same blind spot as the failed one: no error was raised, nothing is polling it, and the file it is chewing on is still billing storage. So it gets its own verdict rather than being folded into "running".

This note deliberately does not read usage or shutdown dates. Whether a fine-tuned model that trained successfully is ever called is a published note that joins the job list to the usage report, and whether new jobs can be created at all is a platform question about dates. Both would need different credentials and would answer different questions. This one holds a project key and reads two endpoints, which is all a failed job requires.

The fix, as a flow

Creating a fine-tuning job is asynchronous, so the 200 that comes back is a receipt rather than a result. Validation and training failures surface on the job object and nowhere else: no exception, no email, and no change in a deploy that keeps working because it is still pointing at the old model. The job has been holding the error code, the offending parameter and an events feed the whole time.

Every job, by statuslist plus events feedFailed with a known codethe documented fix, printedFailed with a new codeprinted verbatim, not guessedHours in validating_filesnot progress, and unwatchedFailed with no error objectthe events feed is all there isSucceededa different note entirely
Two GETs. The job list gives the status and the code; the events feed gives the line that validation actually stopped on.

How to fix it

Use a project key set to Read Only

Both calls are GETs and both are project-scoped. No admin key is needed, no job is created, cancelled or deleted, and no file is touched. The script reads the job list and, for jobs that failed, the events feed for those jobs only.

List every job, not just the recent ones

GET /v1/fine_tuning/jobs?limit=100, paginating on after. If your jobs are tagged, metadata filters narrow the listing. The interesting jobs are usually not the recent ones: a failure from two months ago is exactly the sort of thing that is still quietly absent from a deploy.

Sort by terminal status rather than by date

failed is the finding. succeeded is somebody else's note. cancelled is reported without alarm, because a person did that on purpose. And validating_files older than --stall-hours is treated as its own finding rather than as work in progress.

Read the code, the param and then the events

For each failed job the script prints error.code, error.param and error.message, then pulls GET /v1/fine_tuning/jobs/{id}/events and shows the error-level entries, which is where per-line validation complaints appear. Unknown codes are printed exactly as returned with no interpretation attached.

Fix the input, then fix the pipeline

The immediate repair depends on the code and the script prints it. The durable repair does not: poll the job to a terminal status in CI, and fail the build on anything that is not succeeded. Nothing here re-uploads a file or re-creates a job.

How to check it worked

Re-run after the corrected job finishes. The failed job does not disappear — it is a permanent record and it will keep being reported, which is why the output is grouped by status rather than counted as a single number. What should appear is a succeeded job newer than the failure. Then make the run part of the pipeline that creates jobs, so the next failure is discovered by the build rather than by a question in a meeting.

python3 openai_fine_tune_failures.py --stall-hours 2
# ftjob_a1  failed        base gpt-5.6-terra   created 2026-08-11T18:04:02Z
#   error.code    invalid_training_file
#   error.param   training_file
#   error.message The job failed due to an invalid training file. Unexpected
#                 file format, expected either prompt/completion pairs or chat
#                 messages.
#   event         Validating training file: line 4108 has no assistant message
# job-failed           ftjob_a1: failed on training_file with invalid_training_file
#   repair: the JSONL is malformed. One JSON object per line, no trailing blank
#           line, no BOM, each row a messages array with at least one assistant
#           turn, and one schema across every row.
# ftjob_b2  validating_files  base gpt-5.6-terra  created 2026-08-30T22:41:55Z
# stalled-in-validation ftjob_b2: 9.4 hours in validating_files, which is not
#                       progress
#   repair: read GET /v1/fine_tuning/jobs/ftjob_b2/events for the line that
#           validation stopped on, and delete the file if it is a dead upload.
# 12 job(s), 2 finding(s)

The full code

One paged GET plus one events GET per failed job, and six pure functions. job_row, which reduces a job to the eight fields worth printing and flattens the error object so a missing error and an empty one behave the same; hours_since, which takes the clock as an argument so the stall threshold is testable; classify_job, which separates a failure that explained itself from one that did not and treats a long validation as its own state; error_advice, which translates only the codes that are documented and returns nothing for the rest; error_events, which pulls the error-level entries out of the events feed in the order they happened; and repair_lines, which never invents a fix for a code it does not recognise.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 97 LLM API fixes, free and open source.
openai_fine_tune_failures.py
"""Find fine-tuning jobs that were accepted, then failed, and never read.

Read only. GET /v1/fine_tuning/jobs, paginated, plus GET on the events feed for
jobs that failed. Nothing is created, cancelled, deleted or re-uploaded.

Job creation is asynchronous. The create call returns 200 as soon as the job is
accepted, and validation and training failures surface only on the job object:
status becomes failed, fine_tuned_model and trained_tokens stay null, and error
carries code, message and param. None of that is pushed anywhere.

The error codes are an open set. The documented ones are translated into an
action; everything else is printed exactly as returned, because inventing an
interpretation sends somebody confidently in a direction the API never suggested.

Scope: this note owns a job that failed. Whether a job that succeeded is ever
called is fine-tuned-model-never-used, and whether new jobs can be created at
all is a platform question about dates. Neither is read here.
"""
import argparse
import logging
import os
import sys
import time

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("openai_fine_tune_failures")

JOBS_URL = "https://api.openai.com/v1/fine_tuning/jobs"

TERMINAL = ("succeeded", "failed", "cancelled")

# Only the codes with a documented meaning. Anything else is printed verbatim.
ADVICE = {
    "invalid_training_file":
        "the JSONL is malformed. One JSON object per line, no trailing blank "
        "line, no BOM, each row a messages array with at least one assistant "
        "turn, and one schema across every row.",
    "invalid_validation_file":
        "the validation file has the same problem as a malformed training "
        "file, and error.param says which of the two was rejected.",
    "invalid_n_examples":
        "the example count is out of range: too few rows to train on, or more "
        "than the method accepts. Count the lines before uploading.",
    "exceeded_quota":
        "this is a billing problem rather than a data one. Editing the file "
        "will not help; check the account's quota and spend limits.",
}

FINDINGS = ("job-failed", "failed-without-error", "stalled-in-validation")


def job_row(body):
    """One job, reduced. Pure. The error object is flattened.

    Flattened deliberately: a job with no error key and a job with an empty
    error object mean the same thing to a reader and should not need two code
    paths to say so.
    """
    body = body if isinstance(body, dict) else {}
    error = body.get("error") if isinstance(body.get("error"), dict) else {}
    try:
        created = int(body.get("created_at") or 0)
    except (TypeError, ValueError):
        created = 0
    return {"id": str(body.get("id") or ""),
            "status": str(body.get("status") or ""),
            "model": str(body.get("model") or ""),
            "fine_tuned_model": str(body.get("fine_tuned_model") or ""),
            "created_at": created,
            "code": str((error or {}).get("code") or ""),
            "param": str((error or {}).get("param") or ""),
            "message": str((error or {}).get("message") or "")}


def hours_since(created_at, now):
    """Age in hours. Pure. The clock is an argument."""
    try:
        created = int(created_at)
        now = int(now)
    except (TypeError, ValueError):
        return None
    if created <= 0:
        return None
    return (now - created) / 3600.0


def error_advice(code):
    """The documented meaning of one code. Pure. Empty for anything else."""
    return ADVICE.get(str(code or "").strip(), "")


def error_events(events):
    """Error-level messages in order. Pure. De-duplicated, never reordered."""
    out = []
    for item in events or []:
        if not isinstance(item, dict):
            continue
        if str(item.get("level") or "").lower() != "error":
            continue
        message = str(item.get("message") or "").strip()
        if message and message not in out:
            out.append(message)
    return out


def classify_job(row, now, stall_hours):
    """Grade one job. Pure. Returns (state, detail)."""
    row = row or {}
    status = str(row.get("status") or "")
    job_id = row.get("id") or "(no id)"
    if status == "failed" and row.get("code"):
        return ("job-failed",
                "%s: failed on %s with %s"
                % (job_id, row.get("param") or "an unnamed input",
                   row.get("code")))
    if status == "failed":
        return ("failed-without-error",
                "%s: failed with no error code on the job object, so the events "
                "feed is the only account of why" % job_id)
    if status == "validating_files":
        age = hours_since(row.get("created_at"), now)
        if age is not None and age >= stall_hours:
            return ("stalled-in-validation",
                    "%s: %.1f hours in validating_files, which is not progress"
                    % (job_id, age))
        return ("validating", "%s: validating files" % job_id)
    if status == "succeeded":
        return ("succeeded",
                "%s: succeeded, which is a different note" % job_id)
    if status == "cancelled":
        return ("cancelled", "%s: cancelled by somebody on purpose" % job_id)
    if status in ("queued", "running"):
        return ("running", "%s: %s" % (job_id, status))
    return ("unknown-status",
            "%s: status %r is not one this script recognises"
            % (job_id, status or "(none)"))


def repair_lines(state, code=""):
    """The repair for one verdict. Pure. Printed, never performed."""
    poll = ("poll the job to a terminal status in CI and fail the build on "
            "anything that is not succeeded. A 200 on create is a receipt, not "
            "a result.")
    if state == "job-failed":
        advice = error_advice(code)
        if advice:
            return [advice, poll]
        return ["the code %r is not one this script has a documented meaning "
                "for. Read error.message and the events feed above as printed, "
                "and do not act on a guess." % (code or "(none)"), poll]
    if state == "failed-without-error":
        return ["read GET /v1/fine_tuning/jobs/{id}/events for this job. The "
                "terminal status is all the job object recorded.", poll]
    if state == "stalled-in-validation":
        return ["read the events feed for the line that validation stopped on, "
                "and delete the file if it is a dead upload still counting "
                "against project storage.", poll]
    if state == "succeeded":
        return []
    return []


def fetch_jobs(key, timeout=30):
    """Paged GET of the job list. Returns (rows, error)."""
    rows = []
    params = {"limit": 100}
    headers = {"Authorization": "Bearer " + key}
    for _ in range(100):
        try:
            r = requests.get(JOBS_URL, headers=headers, params=params,
                             timeout=timeout)
        except requests.RequestException as exc:
            return (rows, "request failed: %s" % exc)
        if r.status_code != 200:
            return (rows, "HTTP %d %s" % (r.status_code, (r.text or "")[:160]))
        body = r.json()
        data = body.get("data") or []
        rows.extend(job_row(item) for item in data)
        if not body.get("has_more") or not data:
            break
        params["after"] = data[-1].get("id")
    return (rows, None)


def fetch_events(job_id, key, timeout=30):
    """GET the events feed for one job. Returns a list, empty on any problem."""
    try:
        r = requests.get("%s/%s/events" % (JOBS_URL, job_id),
                         headers={"Authorization": "Bearer " + key},
                         params={"limit": 100}, timeout=timeout)
    except requests.RequestException as exc:
        log.debug("events for %s failed: %s", job_id, exc)
        return []
    if r.status_code != 200:
        return []
    try:
        return list(reversed(r.json().get("data") or []))
    except ValueError:
        return []


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--stall-hours", type=float, default=2.0,
                    help="hours in validating_files that count as stalled")
    args = ap.parse_args()

    key = os.environ.get("OPENAI_API_KEY")
    if not key:
        log.error("set OPENAI_API_KEY to a project key set to Read Only. Both "
                  "calls are GETs of /v1/fine_tuning/jobs")
        return 2

    rows, err = fetch_jobs(key)
    if err:
        log.error("%s", err)
        return 2
    if not rows:
        log.info("no fine-tuning jobs in this project, so there is nothing to "
                 "grade")
        return 0

    now = int(time.time())
    findings = 0
    for row in sorted(rows, key=lambda r: -int(r.get("created_at") or 0)):
        state, detail = classify_job(row, now, args.stall_hours)
        emit = log.warning if state in FINDINGS else log.info
        emit("%-10s %-16s base %-16s created %s", row["id"], row["status"],
             row["model"] or "(none)",
             time.strftime("%Y-%m-%dT%H:%M:%SZ",
                           time.gmtime(row["created_at"] or 0)))
        if row.get("code"):
            emit("  error.code    %s", row["code"])
        if row.get("param"):
            emit("  error.param   %s", row["param"])
        if row.get("message"):
            emit("  error.message %s", row["message"])
        if state in ("job-failed", "failed-without-error",
                     "stalled-in-validation"):
            for message in error_events(fetch_events(row["id"], key))[:5]:
                emit("  event         %s", message)
        emit("%-21s %s", state, detail)
        for line in repair_lines(state, row.get("code")):
            emit("  repair: %s", line)
        if state in FINDINGS:
            findings += 1

    log.info("%d job(s), %d finding(s)", len(rows), findings)
    return 1 if findings else 0


if __name__ == "__main__":
    sys.exit(main())
openai-fine-tune-failures.mjs
/**
 * Find fine-tuning jobs that were accepted, then failed, and never read.
 *
 * Read only. GET /v1/fine_tuning/jobs, paginated, plus GET on the events feed
 * for jobs that failed. Nothing is created, cancelled or deleted.
 *
 * Creation is asynchronous, so the create call returning 200 says only that the
 * job was accepted. Validation and training failures surface on the job object
 * and nowhere else.
 *
 * The error codes are an open set: documented ones are translated into an
 * action, everything else is printed exactly as returned.
 */
const JOBS_URL = 'https://api.openai.com/v1/fine_tuning/jobs';

const ADVICE = {
  invalid_training_file:
    'the JSONL is malformed. One JSON object per line, no trailing blank line, '
    + 'no BOM, each row a messages array with at least one assistant turn, and '
    + 'one schema across every row.',
  invalid_validation_file:
    'the validation file has the same problem as a malformed training file, and '
    + 'error.param says which of the two was rejected.',
  invalid_n_examples:
    'the example count is out of range: too few rows to train on, or more than '
    + 'the method accepts. Count the lines before uploading.',
  exceeded_quota:
    'this is a billing problem rather than a data one. Editing the file will not '
    + "help; check the account's quota and spend limits.",
};

const FINDINGS = new Set(['job-failed', 'failed-without-error',
  'stalled-in-validation']);

/** One job, reduced. Pure. The error object is flattened. */
export function jobRow(body) {
  const job = (body && typeof body === 'object') ? body : {};
  const error = (job.error && typeof job.error === 'object') ? job.error : {};
  const created = Number(job.created_at ?? 0);
  return {
    id: String(job.id ?? ''),
    status: String(job.status ?? ''),
    model: String(job.model ?? ''),
    fine_tuned_model: String(job.fine_tuned_model ?? ''),
    created_at: Number.isFinite(created) ? Math.trunc(created) : 0,
    code: String(error.code ?? ''),
    param: String(error.param ?? ''),
    message: String(error.message ?? ''),
  };
}

/** Age in hours. Pure. The clock is an argument. */
export function hoursSince(createdAt, now) {
  const created = Number(createdAt);
  const at = Number(now);
  if (!Number.isFinite(created) || !Number.isFinite(at) || created <= 0) return null;
  return (at - created) / 3600;
}

/** The documented meaning of one code. Pure. Empty for anything else. */
export function errorAdvice(code) {
  return ADVICE[String(code ?? '').trim()] ?? '';
}

/** Error-level messages in order. Pure. De-duplicated, never reordered. */
export function errorEvents(events) {
  const out = [];
  for (const item of events ?? []) {
    if (!item || typeof item !== 'object') continue;
    if (String(item.level ?? '').toLowerCase() !== 'error') continue;
    const message = String(item.message ?? '').trim();
    if (message && !out.includes(message)) out.push(message);
  }
  return out;
}

/** Grade one job. Pure. Returns [state, detail]. */
export function classifyJob(row, now, stallHours) {
  const job = row ?? {};
  const status = String(job.status ?? '');
  const id = job.id || '(no id)';
  if (status === 'failed' && job.code) {
    return ['job-failed',
      `${id}: failed on ${job.param || 'an unnamed input'} with ${job.code}`];
  }
  if (status === 'failed') {
    return ['failed-without-error',
      `${id}: failed with no error code on the job object, so the events feed is `
      + 'the only account of why'];
  }
  if (status === 'validating_files') {
    const age = hoursSince(job.created_at, now);
    if (age !== null && age >= stallHours) {
      return ['stalled-in-validation',
        `${id}: ${age.toFixed(1)} hours in validating_files, which is not progress`];
    }
    return ['validating', `${id}: validating files`];
  }
  if (status === 'succeeded') {
    return ['succeeded', `${id}: succeeded, which is a different note`];
  }
  if (status === 'cancelled') {
    return ['cancelled', `${id}: cancelled by somebody on purpose`];
  }
  if (status === 'queued' || status === 'running') {
    return ['running', `${id}: ${status}`];
  }
  return ['unknown-status',
    `${id}: status '${status || '(none)'}' is not one this script recognises`];
}

/** The repair for one verdict. Pure. Printed, never performed. */
export function repairLines(state, code = '') {
  const poll = 'poll the job to a terminal status in CI and fail the build on '
    + 'anything that is not succeeded. A 200 on create is a receipt, not a result.';
  if (state === 'job-failed') {
    const advice = errorAdvice(code);
    if (advice) return [advice, poll];
    return [`the code '${code || '(none)'}' is not one this script has a `
      + 'documented meaning for. Read error.message and the events feed above as '
      + 'printed, and do not act on a guess.', poll];
  }
  if (state === 'failed-without-error') {
    return ['read GET /v1/fine_tuning/jobs/{id}/events for this job. The terminal '
      + 'status is all the job object recorded.', poll];
  }
  if (state === 'stalled-in-validation') {
    return ['read the events feed for the line that validation stopped on, and '
      + 'delete the file if it is a dead upload still counting against project '
      + 'storage.', poll];
  }
  return [];
}

async function fetchJobs(key) {
  const rows = [];
  const params = new URLSearchParams({ limit: '100' });
  for (let page = 0; page < 100; page += 1) {
    let res;
    try {
      res = await fetch(`${JOBS_URL}?${params.toString()}`,
        { headers: { Authorization: `Bearer ${key}` } });
    } catch (err) {
      return [rows, `request failed: ${err.message}`];
    }
    if (res.status !== 200) {
      return [rows, `HTTP ${res.status} ${(await res.text()).slice(0, 160)}`];
    }
    const body = await res.json();
    const data = body.data ?? [];
    for (const item of data) rows.push(jobRow(item));
    if (!body.has_more || !data.length) break;
    params.set('after', data[data.length - 1].id);
  }
  return [rows, null];
}

async function fetchEvents(jobId, key) {
  try {
    const res = await fetch(`${JOBS_URL}/${jobId}/events?limit=100`,
      { headers: { Authorization: `Bearer ${key}` } });
    if (res.status !== 200) return [];
    return [...((await res.json()).data ?? [])].reverse();
  } catch {
    return [];
  }
}

function args(argv) {
  const out = { stallHours: 2 };
  for (let i = 0; i < argv.length; i += 1) {
    if (argv[i] === '--stall-hours') out.stallHours = Number(argv[i += 1]);
  }
  return out;
}

async function main() {
  const opts = args(process.argv.slice(2));
  const key = process.env.OPENAI_API_KEY;
  if (!key) {
    console.error('set OPENAI_API_KEY to a project key set to Read Only. Both '
      + 'calls are GETs of /v1/fine_tuning/jobs');
    process.exitCode = 2;
    return;
  }
  const [rows, err] = await fetchJobs(key);
  if (err) {
    console.error(err);
    process.exitCode = 2;
    return;
  }
  if (!rows.length) {
    console.log('no fine-tuning jobs in this project, so there is nothing to grade');
    return;
  }

  const now = Math.trunc(Date.now() / 1000);
  let findings = 0;
  for (const row of [...rows].sort((a, b) => b.created_at - a.created_at)) {
    const [state, detail] = classifyJob(row, now, opts.stallHours);
    const when = row.created_at
      ? `${new Date(row.created_at * 1000).toISOString().slice(0, 19)}Z` : '(unknown)';
    console.log(`${row.id.padEnd(10)} ${row.status.padEnd(16)} base `
      + `${(row.model || '(none)').padEnd(16)} created ${when}`);
    if (row.code) console.log(`  error.code    ${row.code}`);
    if (row.param) console.log(`  error.param   ${row.param}`);
    if (row.message) console.log(`  error.message ${row.message}`);
    if (FINDINGS.has(state)) {
      const events = errorEvents(await fetchEvents(row.id, key)).slice(0, 5);
      for (const message of events) console.log(`  event         ${message}`);
    }
    console.log(`${state.padEnd(21)} ${detail}`);
    for (const line of repairLines(state, row.code)) console.log(`  repair: ${line}`);
    if (FINDINGS.has(state)) findings += 1;
  }
  console.log(`${rows.length} job(s), ${findings} finding(s)`);
  process.exitCode = findings ? 1 : 0;
}

if (import.meta.url === `file://${process.argv[1]}`) await main();

Add a test

The first test is the note in one assertion: a failed job produces job-failed, the detail names the parameter that was rejected before it names anything else, and the repair is the documented meaning of the code rather than a general apology. The second is the honesty rule — an error code the script does not recognise must still be a finding, must be printed verbatim, and must come with an instruction not to act on a guess, because a diagnostic that invents meanings is worse than one that prints strings. The third is the state everybody folds into running: hours in validating_files has to be its own verdict, and the same job an hour after creation must not be. Then the failed job with an empty error object, which is handed to the events feed. Then the boundary that keeps this out of the neighbouring note: a succeeded job is not a finding and its repair list is empty. And last the events feed, filtered to error level and de-duplicated without being reordered.

test_openai_fine_tune_failures.py
from openai_fine_tune_failures import (classify_job, error_advice,
                                      error_events, hours_since, job_row,
                                      repair_lines)

NOW = 1_800_000_000
HOUR = 3600


def job(status, code="", param="", message="", hours_old=1.0, jid="ftjob_a1"):
    return job_row({"id": jid, "object": "fine_tuning.job", "status": status,
                    "model": "gpt-5.6-terra",
                    "created_at": NOW - int(hours_old * HOUR),
                    "fine_tuned_model": None, "trained_tokens": None,
                    "error": ({"code": code, "message": message,
                               "param": param} if code or message else None)})


def test_a_failed_job_names_the_rejected_input_and_the_documented_fix():
    row = job("failed", "invalid_training_file", "training_file",
              "The job failed due to an invalid training file.")
    state, detail = classify_job(row, NOW, 2.0)
    assert state == "job-failed"
    assert "failed on training_file with invalid_training_file" in detail
    lines = repair_lines(state, row["code"])
    assert lines[0] == error_advice("invalid_training_file")
    assert "no trailing blank line" in lines[0]
    assert any("receipt, not a result" in line for line in lines)


def test_an_unknown_code_is_printed_and_never_interpreted():
    row = job("failed", "some_new_code_2027", "training_file", "...")
    state, _ = classify_job(row, NOW, 2.0)
    assert state == "job-failed"
    assert error_advice("some_new_code_2027") == ""
    lines = repair_lines(state, row["code"])
    assert "some_new_code_2027" in lines[0]
    assert "do not act on a guess" in lines[0]
    # exceeded_quota is documented, and it is not a data problem.
    assert "billing problem" in error_advice("exceeded_quota")
    assert "Editing the file will not help" in error_advice("exceeded_quota")


def test_hours_in_validating_files_is_its_own_finding():
    stalled = job("validating_files", hours_old=9.4, jid="ftjob_b2")
    state, detail = classify_job(stalled, NOW, 2.0)
    assert state == "stalled-in-validation"
    assert "9.4 hours in validating_files" in detail
    assert any("dead upload" in line for line in repair_lines(state))
    # The same job an hour in is simply validating.
    fresh = job("validating_files", hours_old=1.0, jid="ftjob_b3")
    assert classify_job(fresh, NOW, 2.0)[0] == "validating"
    assert abs(hours_since(NOW - 5 * HOUR, NOW) - 5.0) < 1e-9
    assert hours_since(0, NOW) is None


def test_a_failure_with_no_error_object_is_sent_to_the_events_feed():
    row = job("failed")
    assert row["code"] == "" and row["param"] == ""
    state, detail = classify_job(row, NOW, 2.0)
    assert state == "failed-without-error"
    assert "the only account of why" in detail
    assert any("/events" in line for line in repair_lines(state))


def test_a_succeeded_job_is_handed_to_the_other_note():
    state, detail = classify_job(job("succeeded", hours_old=200.0), NOW, 2.0)
    assert state == "succeeded"
    assert "a different note" in detail
    assert repair_lines(state) == []
    assert classify_job(job("cancelled"), NOW, 2.0)[0] == "cancelled"
    assert classify_job(job("running"), NOW, 2.0)[0] == "running"
    assert classify_job(job("beaming_up"), NOW, 2.0)[0] == "unknown-status"


def test_the_events_feed_is_filtered_to_errors_and_kept_in_order():
    feed = [{"level": "info", "message": "Created fine-tuning job"},
            {"level": "error", "message": "line 4108 has no assistant message"},
            {"level": "warn", "message": "..."},
            {"level": "ERROR", "message": "line 4108 has no assistant message"},
            {"level": "error", "message": "validation failed"},
            "not a dict"]
    assert error_events(feed) == ["line 4108 has no assistant message",
                                  "validation failed"]
    assert error_events(None) == []
    assert job_row(None)["id"] == ""
    assert job_row({"created_at": "nonsense"})["created_at"] == 0
openai-fine-tune-failures.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { classifyJob, errorAdvice, errorEvents, hoursSince, jobRow,
         repairLines } from './openai-fine-tune-failures.mjs';

const NOW = 1_800_000_000;
const HOUR = 3600;

const job = (status, { code = '', param = '', message = '', hoursOld = 1,
                       id = 'ftjob_a1' } = {}) => jobRow({
  id,
  object: 'fine_tuning.job',
  status,
  model: 'gpt-5.6-terra',
  created_at: NOW - Math.trunc(hoursOld * HOUR),
  fine_tuned_model: null,
  trained_tokens: null,
  error: (code || message) ? { code, message, param } : null,
});

test('a failed job names the rejected input and the documented fix', () => {
  const row = job('failed', { code: 'invalid_training_file', param: 'training_file',
                              message: 'The job failed due to an invalid training file.' });
  const [state, detail] = classifyJob(row, NOW, 2);
  assert.equal(state, 'job-failed');
  assert.ok(detail.includes('failed on training_file with invalid_training_file'));
  const lines = repairLines(state, row.code);
  assert.equal(lines[0], errorAdvice('invalid_training_file'));
  assert.ok(lines[0].includes('no trailing blank line'));
  assert.ok(lines.some((l) => l.includes('receipt, not a result')));
});

test('an unknown code is printed and never interpreted', () => {
  const row = job('failed', { code: 'some_new_code_2027', param: 'training_file',
                              message: '...' });
  assert.equal(classifyJob(row, NOW, 2)[0], 'job-failed');
  assert.equal(errorAdvice('some_new_code_2027'), '');
  const lines = repairLines('job-failed', row.code);
  assert.ok(lines[0].includes('some_new_code_2027'));
  assert.ok(lines[0].includes('do not act on a guess'));
  assert.ok(errorAdvice('exceeded_quota').includes('billing problem'));
  assert.ok(errorAdvice('exceeded_quota').includes('Editing the file will not help'));
});

test('hours in validating_files is its own finding', () => {
  const stalled = job('validating_files', { hoursOld: 9.4, id: 'ftjob_b2' });
  const [state, detail] = classifyJob(stalled, NOW, 2);
  assert.equal(state, 'stalled-in-validation');
  assert.ok(detail.includes('9.4 hours in validating_files'));
  assert.ok(repairLines(state).some((l) => l.includes('dead upload')));
  assert.equal(classifyJob(job('validating_files', { hoursOld: 1 }), NOW, 2)[0],
               'validating');
  assert.ok(Math.abs(hoursSince(NOW - 5 * HOUR, NOW) - 5) < 1e-9);
  assert.equal(hoursSince(0, NOW), null);
});

test('a failure with no error object is sent to the events feed', () => {
  const row = job('failed');
  assert.equal(row.code, '');
  assert.equal(row.param, '');
  const [state, detail] = classifyJob(row, NOW, 2);
  assert.equal(state, 'failed-without-error');
  assert.ok(detail.includes('the only account of why'));
  assert.ok(repairLines(state).some((l) => l.includes('/events')));
});

test('a succeeded job is handed to the other note', () => {
  const [state, detail] = classifyJob(job('succeeded', { hoursOld: 200 }), NOW, 2);
  assert.equal(state, 'succeeded');
  assert.ok(detail.includes('a different note'));
  assert.deepEqual(repairLines(state), []);
  assert.equal(classifyJob(job('cancelled'), NOW, 2)[0], 'cancelled');
  assert.equal(classifyJob(job('running'), NOW, 2)[0], 'running');
  assert.equal(classifyJob(job('beaming_up'), NOW, 2)[0], 'unknown-status');
});

test('the events feed is filtered to errors and kept in order', () => {
  const feed = [{ level: 'info', message: 'Created fine-tuning job' },
                { level: 'error', message: 'line 4108 has no assistant message' },
                { level: 'warn', message: '...' },
                { level: 'ERROR', message: 'line 4108 has no assistant message' },
                { level: 'error', message: 'validation failed' },
                'not a dict'];
  assert.deepEqual(errorEvents(feed),
                   ['line 4108 has no assistant message', 'validation failed']);
  assert.deepEqual(errorEvents(null), []);
  assert.equal(jobRow(null).id, '');
  assert.equal(jobRow({ created_at: 'nonsense' }).created_at, 0);
});

FAQ

Why does creating the job return 200 if it is going to fail?

Because creation and training are different operations and only the first one is synchronous. The 200 means the job was accepted into the queue. From there it moves through validating_files, queued and running to a terminal state, and everything that can go wrong with your data goes wrong inside that sequence. The Files API is the same shape: it accepts any bytes for a fine-tune purpose without parsing them, so an unusable file uploads perfectly and fails much later, in the job.

What do the error codes actually mean?

A few are documented and the script translates those: a malformed training file, an example count out of range, an exhausted quota. error.param is often more useful than the code, because it names which input was rejected. Beyond that the set is open and the script prints unrecognised codes exactly as returned, with an instruction not to act on a guess. If the code is unfamiliar, error.message and the events feed are the two places with the real detail.

The job has been validating_files for hours. Is it just slow?

Treat it as stuck. Validation is quick, and a job that has been validating for hours occupies the same blind spot a failed one does: nothing raised, nothing is polling it, and the file it is working on is still counting against project storage. The events feed usually names the line it stopped on. That is why this gets its own verdict rather than being reported as a job in progress, and why the threshold is a flag you can lower.

Is this the same as a fine-tune nobody uses?

No, and they are worth keeping apart because the repairs are opposite. A model that trained successfully and gets no traffic is a deployment problem: the training was billed, the model exists, and the routing never changed. That is a separate published note and it needs a usage report and an admin key to prove. This one is about a job that never produced a model at all, which needs only the job object and its events, and whose repair is to fix an input and run it again.

How do we stop finding these weeks late?

Poll the job to a terminal status in the pipeline that creates it, and fail the build on anything that is not succeeded. That one change turns this class of failure from a silent absence into a red build within the hour. Running this script on a schedule is the backstop rather than the fix: it catches the jobs somebody started by hand, which are exactly the ones nobody is watching.

Related field notes

Sources

Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.