Skip to content

Diagnostic LLM APIs

Fine-tuning stops taking new jobs while old ones keep serving

Nothing is broken, which is the problem. The classifier fine-tune is serving traffic, the job list is full of green, and the plan — retrain it in the new year when there is time — sounds entirely reasonable to everybody in the room. It is not reasonable, and the reason is not on any dashboard: nobody has run inference against a fine-tuned model in about nine weeks, because the classifier was quietly replaced by a prompt in June and only the old batch job still calls it. The right to create a fine-tuning job expired somewhere in there. There was no notification, because nothing happened.

Read-only key Python and Node.js Tests included
A headset near a laptop
Photo by Petr Machacek on Unsplash
The short answer

Split the resource by verb, because the two halves have different deadlines and only one of them is on a calendar. Creating a job and serving an existing fine-tune are separate rights with separate clocks, and an organization can lose the first while the second still looks perfectly healthy.

The create side ramps in three steps. From 2026-05-07 an organization that has never run fine-tuning cannot start; from 2026-07-02 an organization that has not run inference on a fine-tuned model in the past 60 days cannot start; and from 2027-01-06 nobody can. That middle rule is the one worth a script, because it is not a date you can diary. It is a rolling window over your own traffic, so it closes silently on a Tuesday when the last ft: call ages out.

And it is readable. GET /v1/fine_tuning/jobs with a project read key tells you whether this organization has ever fine-tuned; GET /v1/organization/usage/completions grouped by model with an admin-read key tells you the last day any ft: prefixed id produced requests. Those two facts decide eligibility. The script computes the clock from them and never submits a job to test it — a note about an endpoint that stopped accepting work has no business submitting work.

The serving side is a date and a different one: every fine-tunable base in the deprecation table shuts down 2026-10-23, which is before the create cutoff arrives. So the two clocks cross. Your existing fine-tunes stop serving in October, and the window to retrain onto a supported base closes in January. Discover that in October and you have about ten weeks, once, ever.

The script reads shutdown_date off each fine-tuned model and off its base where the API supplies one, and falls back to the published table by family where it does not — labelling which is which, because those are different grades of evidence.

The problem in plain words

Announced 7 May 2026, self-serve fine-tuning is being wound down in stages rather than switched off. That is generous and it is also why it goes unnoticed: a staged withdrawal produces no single failing day for most organizations, and the stage that catches people is the second one, which is not a date at all. From 2 July 2026, creating a job requires that the organization has run inference on a fine-tuned model within the last 60 days.

Read that again as an operational fact. Eligibility is a rolling window over your own traffic. It closes without a deploy, without an error, and without anybody doing anything — specifically, it closes because somebody did nothing, which is the hardest kind of change to notice. A team that replaced its fine-tune with a prompt in the spring is disqualified from retraining it in the autumn, and the first evidence is a rejected create call at the exact moment they need one.

The other half of the wind-down is a hard date and it lands first. Every fine-tunable base named in the deprecation table — the ft-gpt-3.5-turbo, ft-gpt-4, ft-gpt-4.1-nano-2025-04-14, ft-babbage-002, ft-davinci-002 and ft-o4-mini-2025-04-16 families — shuts down on 23 October 2026. Inference on a fine-tune dies with its base, so the model serving your traffic today has a date on it whether or not anybody has looked.

Put together, the dates cross in the worst possible order. October removes the models. January removes the ability to make new ones. An organization that discovers the first in October has one narrow window to retrain everything it wants to keep, and if the 60-day inference clock has also lapsed in the meantime, it does not have that window either.

Fine tuneserves trafficeverything greenReplaced by apromptone batch job leftLast ft callages outsixty days,quietlyRetrain is nowrefusedon the day it isneededBases die inOctoberbefore the retrainwindow
No error, no deploy, no notification. The window closed because somebody stopped doing something, which is the hardest change to see.

Why it happens

One resource, two verbs, and the script reports them as two sections. Everything else in this batch closes an endpoint outright. Here the endpoint keeps answering: jobs list, models resolve, inference bills. What stops is one verb, on a schedule that has nothing to do with the other. Merging them into a single verdict would produce the exact sentence that gets somebody into trouble — "fine-tuning is fine" — which is true of one half and false of the other.

Eligibility is computed from readable state, never from an attempt. The obvious way to find out whether you can still create a job is to create one. That is unacceptable here: it spends money, it trains a model nobody asked for, and it is a write in a section that promises never to write. So the eligibility function takes exactly three readable inputs — the date, whether the job list is non-empty, and days since the last ft: request — and there is a test that a blocked verdict is reached from those alone.

The same zero means the opposite of what it means in the published note next door. The note about a fine-tune nobody calls reads zero traffic on a trained model as waste: you paid to train it and never routed to it, so either route or retire. Here zero ft: traffic is an eligibility clock that has already run out. Same number, opposite meaning, opposite repair — that note tells you to retire the model, and this one tells you the traffic is the thing keeping your right to retrain alive.

A date read off the API and a date read off a published table are labelled differently. Where GET /v1/models/{id} returns a shutdown_date, that is the authority and the script says so. Where it does not, the deprecation table's family row is the only source, and the script says that, because a published table lags and a reader deserves to know which kind of claim they are looking at.

Base matching is exact or hyphen-delimited, and never a loose prefix. gpt-4.1-nano-2025-04-14 starts with the characters gpt-4, and a careless prefix match files it under the wrong family with the wrong replacement. A base the table does not cover comes back as unknown rather than as the nearest-looking row, which is the same discipline the Sora note applies to replacements.

This is not the published note about model ids in your configuration. That one greps your config strings and diffs them against the model list. Nothing here reads your configuration at all: the base model comes off the job object, where the platform recorded it, and the fine-tuned id comes off the same object. The unit is a job, not a string in a file.

The fix, as a flow

This is the only note in the batch where the endpoint keeps working. Jobs list, models resolve, inference bills, and one verb has quietly stopped being accepted. Creating and serving are separate rights with separate clocks, and the create clock is not a date at all: it is a rolling window over your own recent traffic, which closes on a quiet week when nobody does anything. That is readable, so the script computes it rather than testing it by submitting a job.

One resource, two verbscreate against serveNo ft traffic in 60 dayscreate already refusedJob list is emptycreate was never availableWindow closing this monthretrain while you still canBase dated by the APIserving ends on that dayBase has no date anywhereundated, and not safe
Each outcome names a verb. The pair that matters is the one where they disagree: refused on create, healthy on serve.

How to fix it

List the jobs, which answers the first eligibility question

GET /v1/fine_tuning/jobs?limit=100, paginating on after. An empty list past 2026-05-07 is already a blocked organization, and that is established from a listing rather than from an attempt.

Read the last day any ft: model produced requests

GET /v1/organization/usage/completions?bucket_width=1d&group_by[]=model over about 70 days with an admin-read key. Any results[].model starting ft: with requests against it dates the clock. No admin key means no clock, and the script reports that as unknown rather than as fine.

Compute the create clock, and do not test it by creating

Three inputs, all readable: the date, whether the job list is non-empty, and days since the last ft: request. Past 60 the create right is gone; between 45 and 60 it is expiring and the script prints the days left. Nothing submits a job to confirm this.

Read each fine-tune's serving deadline, and say where it came from

Per succeeded job: GET /v1/models/{fine_tuned_model}, then the base, for a shutdown_date. Where the API gives one, that is the answer and it is labelled as measured. Where it does not, the family row in the deprecation table supplies 2026-10-23 and is labelled as published.

Print both halves and the order the work has to happen in

Retraining needs eligibility, and eligibility needs recent ft: inference, so the sequence matters: keep the clock alive, retrain onto a supported base before October, and stop treating January as the deadline. It is the outside edge, not the schedule.

How to check it worked

Re-run after the retraining pass. The create clock should reset, because a new job and the inference that follows it both refresh the 60-day window — that is the reading that proves the eligibility half is fixed. The serving half is proved differently: each retrained model's deadline should now come back from the API with a later date or none at all, rather than from the published table. A row that still says published-table is a fine-tune whose base the model object will not talk about, and it should stay visible.

OPENAI_API_KEY=sk-proj-... OPENAI_ADMIN_KEY=sk-admin-... \
  python3 fine_tuning_gate_audit.py
# create: 4 job(s) in the list, last ft: inference 63 day(s) ago
# blocked-no-recent-inference  no fine-tuned model has served a request for 63 day(s),
#                              and the 60 day rule has applied since 2026-07-02, so new
#                              jobs are already being refused. Read from usage, not from
#                              an attempt
#   repair: route real traffic to a fine-tune to reopen the window, or accept that this
#           organization is out of the fine-tuning business as of 2026-07-02
# serve: 3 succeeded job(s)
#   ft:gpt-4.1-nano-2025-04-14:acme::Ab12  2026-10-23  published-table  dying-soon
#                                          53 day(s) of inference left; retrain onto
#                                          gpt-5.6-luna
#   ft:gpt-3.5-turbo:acme::Cd34            2026-10-23  published-table  dying-soon
#                                          53 day(s) of inference left; retrain onto
#                                          gpt-5.6-terra
#   ft:gpt-4o-mini-2024-07-18:acme::Ef56   ---         unknown          no-base-date
# 4 finding(s)

The full code

One paginated job listing, one usage report, two model lookups per job, and six pure functions. days_left, arithmetic against three published dates rather than one; create_eligibility, the centre of the note, which takes only readable inputs so that a test can prove a blocked verdict is reached without submitting anything; family_for, which matches a base exactly or on a hyphen boundary so gpt-4.1-nano can never collapse into gpt-4; serving_deadline, which returns the date together with where it came from, because a field the API stated and a row in a published table are different grades of evidence; job_verdict, which grades one fine-tune against its own deadline; and repair_lines, which prints two repairs because there are two verbs.

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.
fine_tuning_gate_audit.py
"""Grade two verbs on one resource: creating a fine-tuning job, and serving one.

Read only. Every request is a GET: the job listing, the organization usage
report, and the model objects for each fine-tune and its base. Nothing here
submits a job. That matters more than usual: the obvious way to find out
whether creation is still accepted is to attempt one, and attempting one spends
money, trains a model nobody asked for, and is a write.

So eligibility is computed from readable state instead. Three inputs decide it,
all of them readable with the keys this script already holds: the date, whether
the job list is non-empty, and how long it has been since any ft: prefixed
model produced a request.

The serving side is a separate clock and lands first. Every fine-tunable base
in the deprecation table shuts down 2026-10-23, before the create cutoff
arrives, so the two deadlines cross in the worst order.
"""
import argparse
import datetime as dt
import logging
import os
import sys

import requests

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

API = "https://api.openai.com/v1"

# Announced 7 May 2026, in three stages. The middle one is the only rule here
# that is not a date: it is a rolling 60 day window over your own traffic.
NEVER_FINE_TUNED = "2026-05-07"   # never fine-tuned before: cannot create
NO_RECENT_INFERENCE = "2026-07-02"  # no ft: inference in 60 days: cannot create
CUTOFF = "2027-01-06"             # nobody can create
WINDOW = 60                       # days of ft: inference the middle rule wants

# Inference on a fine-tune dies with its base, and every fine-tunable base in
# the deprecation table shuts down on this date. Used only as the fallback when
# the model object itself carries no shutdown_date, and labelled as such.
BASE_SHUTDOWN = "2026-10-23"

# The deprecation table's six fine-tuned families and their replacements. The
# match is exact or hyphen-delimited, never a loose prefix: gpt-4.1-nano starts
# with the characters gpt-4 and must not be filed under ft-gpt-4 with the wrong
# replacement. A base the table does not cover comes back unknown rather than
# as the nearest-looking row.
FAMILIES = (
    ("gpt-3.5-turbo", "ft-gpt-3.5-turbo", "gpt-5.6-terra"),
    ("gpt-4.1-nano-2025-04-14", "ft-gpt-4.1-nano-2025-04-14", "gpt-5.6-luna"),
    ("gpt-4", "ft-gpt-4", "gpt-5.6-sol"),
    ("babbage-002", "ft-babbage-002", "gpt-5.6-terra"),
    ("davinci-002", "ft-davinci-002", "gpt-5.6-terra"),
    ("o4-mini-2025-04-16", "ft-o4-mini-2025-04-16", "gpt-5.6-terra"),
)

FINDINGS = ("blocked-never-fine-tuned", "blocked-no-recent-inference",
            "eligibility-expiring", "create-closed", "unknown-eligibility",
            "already-dead", "dying-soon", "no-base-date")

REPAIRS = {
    "blocked-never-fine-tuned":
        "this organization has no fine-tuning history and the "
        + NEVER_FINE_TUNED + " restriction has passed, so creation is already "
        "refused. Nothing reopens that.",
    "blocked-no-recent-inference":
        "route real traffic to a fine-tune to reopen the window, or accept "
        "that this organization is out of the fine-tuning business as of "
        + NO_RECENT_INFERENCE + ".",
    "eligibility-expiring":
        "the 60 day window is closing. Either retrain now, while creating a "
        "job is still permitted, or keep a real workload on a fine-tuned model "
        "so the clock does not run out on a quiet week.",
    "create-closed":
        "the " + CUTOFF + " cutoff has passed and no organization can create "
        "a fine-tuning job. Whatever is still serving is the last of it.",
    "unknown-eligibility":
        "the inference clock could not be read, so eligibility is unknown "
        "rather than fine. Re-run with an admin-read key before planning "
        "around it.",
    "already-dead":
        "the base is past its shutdown date, so this fine-tune has stopped "
        "serving. Retraining onto a supported base is the only route back, and "
        "it is only available until " + CUTOFF + ".",
    "dying-soon":
        "retrain onto the supported base before the date. Where the fine-tune "
        "only ever encoded formatting, evaluate replacing it with prompting "
        "plus structured outputs instead of retraining at all.",
    "no-base-date":
        "neither the model object nor the published table has a date for this "
        "base, so its serving deadline is unknown. Treat it as undated rather "
        "than as safe.",
}


def days_left(today, when):
    """Whole days from today to a date. Pure. Negative once it has passed."""
    return (dt.date.fromisoformat(str(when))
            - dt.date.fromisoformat(str(today))).days


def create_eligibility(today, has_prior_jobs, days_since_ft_inference):
    """Can this organization still create a job? Pure. (state, detail).

    Three readable inputs and nothing else. days_since_ft_inference is an int,
    the string "none-in-window" when the usage window held no ft: traffic at
    all, or None when it could not be read. There is a test that a blocked
    verdict comes out of these alone, because the alternative way to answer
    this question is to submit a job, and this script never will.
    """
    if days_left(today, CUTOFF) < 0:
        return ("create-closed",
                "the %s cutoff has passed, so no organization can create a "
                "fine-tuning job" % CUTOFF)
    if not has_prior_jobs and days_left(today, NEVER_FINE_TUNED) < 0:
        return ("blocked-never-fine-tuned",
                "the job list is empty and the %s restriction has passed, so "
                "this organization cannot create a job today. Read from the "
                "listing, not from an attempt" % NEVER_FINE_TUNED)
    if days_left(today, NO_RECENT_INFERENCE) >= 0:
        return ("eligible",
                "the 60 day inference rule does not apply until %s; %d day(s) "
                "until the %s cutoff"
                % (NO_RECENT_INFERENCE, days_left(today, CUTOFF), CUTOFF))
    if days_since_ft_inference is None:
        return ("unknown-eligibility",
                "the inference clock could not be read, so eligibility is "
                "unknown rather than fine")
    if days_since_ft_inference == "none-in-window":
        return ("blocked-no-recent-inference",
                "no fine-tuned model produced a request anywhere in the window "
                "read, so the %d day rule has already closed creation. Read "
                "from usage, not from an attempt" % WINDOW)
    days = int(days_since_ft_inference)
    if days > WINDOW:
        return ("blocked-no-recent-inference",
                "no fine-tuned model has served a request for %d day(s), and "
                "the %d day rule has applied since %s, so new jobs are already "
                "being refused. Read from usage, not from an attempt"
                % (days, WINDOW, NO_RECENT_INFERENCE))
    if days > 45:
        return ("eligibility-expiring",
                "the last fine-tuned request was %d day(s) ago, so %d day(s) "
                "of the %d day window are left"
                % (days, WINDOW - days, WINDOW))
    return ("eligible",
            "the last fine-tuned request was %d day(s) ago and %d day(s) "
            "remain until the %s cutoff"
            % (days, days_left(today, CUTOFF), CUTOFF))


def family_for(base_model):
    """The deprecation family and replacement for a base. Pure. (family, to).

    Exact or hyphen-delimited, never a loose prefix. gpt-4.1-nano-2025-04-14
    starts with the characters gpt-4, and filing it under ft-gpt-4 would print
    the wrong replacement with complete confidence.
    """
    base = str(base_model or "")
    for prefix, family, replacement in FAMILIES:
        if base == prefix or base.startswith(prefix + "-"):
            return (family, replacement)
    return (None, None)


def serving_deadline(api_shutdown_date, family):
    """When this fine-tune stops serving. Pure. (date, source, detail).

    Returns where the date came from as well as the date. A field the API
    stated and a row in a published table are different grades of evidence and
    a reader is entitled to know which one they are looking at.
    """
    if api_shutdown_date:
        return (str(api_shutdown_date), "api",
                "shutdown_date read off the model object")
    if family:
        return (BASE_SHUTDOWN, "published-table",
                "the model object carried no shutdown_date, so this is the %s "
                "row in the deprecation table" % family)
    return (None, "unknown",
            "neither the model object nor the published table has a date for "
            "this base")


def job_verdict(status, fine_tuned_model, deadline, today):
    """Grade one job's serving half. Pure. Returns (state, detail)."""
    if str(status) != "succeeded" or not fine_tuned_model:
        return ("not-serving",
                "status %s with no fine-tuned model, so nothing is serving "
                "from this job" % status)
    if not deadline:
        return ("no-base-date",
                "no serving deadline could be established for this base")
    left = days_left(today, deadline)
    if left < 0:
        return ("already-dead",
                "the base shut down %d day(s) ago, so this fine-tune has "
                "stopped serving" % -left)
    if left <= 90:
        return ("dying-soon", "%d day(s) of inference left" % left)
    return ("serving", "%d day(s) of inference left" % left)


def repair_lines(state, replacement=None):
    """The repair for one verdict. Pure. Printed, never performed."""
    line = REPAIRS.get(state)
    if not line:
        return []
    if state in ("dying-soon", "already-dead") and replacement:
        return [line, "the documented replacement base is %s." % replacement]
    if state == "blocked-no-recent-inference":
        return [line,
                "note the order the dates fall in: the bases die %s and the "
                "right to retrain closes %s, so October is the deadline and "
                "January is only the outside edge."
                % (BASE_SHUTDOWN, CUTOFF)]
    return [line]


def get_json(session, path, key, params=None, timeout=30):
    """One GET. Returns (status, parsed body). Never raises on a 4xx."""
    try:
        r = session.get(API + path,
                        headers={"Authorization": "Bearer " + key},
                        params=params or {}, timeout=timeout)
    except requests.RequestException as exc:
        log.debug("GET %s failed: %s", path, exc)
        return (None, {})
    try:
        return (r.status_code, r.json())
    except ValueError:
        return (r.status_code, {})


def all_jobs(session, key, pages=20):
    """Walk GET /v1/fine_tuning/jobs to the end."""
    out, after = [], None
    for _ in range(pages):
        params = {"limit": 100}
        if after:
            params["after"] = after
        status, body = get_json(session, "/fine_tuning/jobs", key, params)
        if status != 200:
            log.warning("job listing came back %s, so eligibility cannot be "
                        "read from it", status)
            break
        page = body.get("data") or []
        out.extend(page)
        if not page or not body.get("has_more"):
            break
        after = page[-1].get("id")
        if not after:
            break
    return out


def days_since_ft_inference(session, key, today, days=70):
    """Days since any ft: model produced a request. Int, sentinel, or None."""
    start = int((dt.datetime.now(dt.timezone.utc)
                 - dt.timedelta(days=days)).timestamp())
    status, body = get_json(session, "/organization/usage/completions", key,
                            {"start_time": start, "bucket_width": "1d",
                             "group_by[]": ["model"], "limit": 180})
    if status != 200:
        log.warning("usage report came back %s, so the inference clock could "
                    "not be read", status)
        return None
    last = None
    for bucket in body.get("data") or []:
        stamp = bucket.get("start_time")
        if not stamp:
            continue
        day = dt.datetime.fromtimestamp(int(stamp),
                                        dt.timezone.utc).date().isoformat()
        for row in bucket.get("results") or []:
            model = str(row.get("model") or "")
            if model.startswith("ft:") and (row.get("num_model_requests") or 0) > 0:
                last = day if last is None else max(last, day)
    if last is None:
        return "none-in-window"
    return -days_left(today, last)


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--today", default=dt.date.today().isoformat(),
                    help="override the date the arithmetic is done against")
    args = ap.parse_args()

    key = os.environ.get("OPENAI_API_KEY")
    if not key:
        log.error("set OPENAI_API_KEY to a project read key. This script only "
                  "issues GET requests and never submits a job")
        return 2

    session = requests.Session()
    findings = 0

    jobs = all_jobs(session, key)
    admin = os.environ.get("OPENAI_ADMIN_KEY")
    since = (days_since_ft_inference(session, admin, args.today)
             if admin else None)
    log.info("create: %d job(s) in the list, last ft: inference %s", len(jobs),
             "unknown" if since is None else
             "not in the window" if since == "none-in-window" else
             "%d day(s) ago" % since)

    state, detail = create_eligibility(args.today, bool(jobs), since)
    emit = log.warning if state in FINDINGS else log.info
    emit("%-28s %s", state, detail)
    for line in repair_lines(state):
        emit("  repair: %s", line)
    if state in FINDINGS:
        findings += 1

    succeeded = [j for j in jobs if str(j.get("status")) == "succeeded"
                 and j.get("fine_tuned_model")]
    log.info("serve: %d succeeded job(s)", len(succeeded))
    for job in succeeded:
        ftm = job.get("fine_tuned_model")
        base = job.get("model")
        family, replacement = family_for(base)
        _, ftm_body = get_json(session, "/models/" + str(ftm), key)
        shutdown = (ftm_body or {}).get("shutdown_date")
        if not shutdown and base:
            _, base_body = get_json(session, "/models/" + str(base), key)
            shutdown = (base_body or {}).get("shutdown_date")
        deadline, source, why = serving_deadline(shutdown, family)
        state, detail = job_verdict(job.get("status"), ftm, deadline,
                                    args.today)
        emit = log.warning if state in FINDINGS else log.info
        emit("  %-40s %-11s %-16s %-13s %s", ftm, deadline or "---", source,
             state, detail)
        log.debug("    %s", why)
        for line in repair_lines(state, replacement):
            emit("    repair: %s", line)
        if state in FINDINGS:
            findings += 1

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


if __name__ == "__main__":
    sys.exit(main())
fine-tuning-gate-audit.mjs
/**
 * Grade two verbs on one resource: creating a fine-tuning job, and serving one.
 *
 * Read only. Every request is a GET: the job listing, the usage report, and
 * the model objects for each fine-tune and its base. Nothing here submits a
 * job. The obvious way to find out whether creation is still accepted is to
 * attempt one, and attempting one spends money, trains a model nobody asked
 * for, and is a write.
 *
 * So eligibility is computed from readable state: the date, whether the job
 * list is non-empty, and how long since any ft: model produced a request.
 */
export const API = 'https://api.openai.com/v1';

// Announced 7 May 2026, in three stages. The middle one is a rolling window
// over your own traffic rather than a date.
export const NEVER_FINE_TUNED = '2026-05-07';
export const NO_RECENT_INFERENCE = '2026-07-02';
export const CUTOFF = '2027-01-06';
export const WINDOW = 60;

// Inference on a fine-tune dies with its base. Fallback only, and labelled.
export const BASE_SHUTDOWN = '2026-10-23';

// Exact or hyphen-delimited, never a loose prefix: gpt-4.1-nano starts with
// the characters gpt-4 and must not be filed under ft-gpt-4.
export const FAMILIES = [
  ['gpt-3.5-turbo', 'ft-gpt-3.5-turbo', 'gpt-5.6-terra'],
  ['gpt-4.1-nano-2025-04-14', 'ft-gpt-4.1-nano-2025-04-14', 'gpt-5.6-luna'],
  ['gpt-4', 'ft-gpt-4', 'gpt-5.6-sol'],
  ['babbage-002', 'ft-babbage-002', 'gpt-5.6-terra'],
  ['davinci-002', 'ft-davinci-002', 'gpt-5.6-terra'],
  ['o4-mini-2025-04-16', 'ft-o4-mini-2025-04-16', 'gpt-5.6-terra'],
];

const FINDINGS = new Set(['blocked-never-fine-tuned', 'blocked-no-recent-inference',
  'eligibility-expiring', 'create-closed', 'unknown-eligibility', 'already-dead',
  'dying-soon', 'no-base-date']);

const REPAIRS = {
  'blocked-never-fine-tuned':
    `this organization has no fine-tuning history and the ${NEVER_FINE_TUNED} `
    + 'restriction has passed, so creation is already refused. Nothing reopens that.',
  'blocked-no-recent-inference':
    'route real traffic to a fine-tune to reopen the window, or accept that this '
    + `organization is out of the fine-tuning business as of ${NO_RECENT_INFERENCE}.`,
  'eligibility-expiring':
    'the 60 day window is closing. Either retrain now, while creating a job is '
    + 'still permitted, or keep a real workload on a fine-tuned model so the clock '
    + 'does not run out on a quiet week.',
  'create-closed':
    `the ${CUTOFF} cutoff has passed and no organization can create a fine-tuning `
    + 'job. Whatever is still serving is the last of it.',
  'unknown-eligibility':
    'the inference clock could not be read, so eligibility is unknown rather than '
    + 'fine. Re-run with an admin-read key before planning around it.',
  'already-dead':
    'the base is past its shutdown date, so this fine-tune has stopped serving. '
    + 'Retraining onto a supported base is the only route back, and it is only '
    + `available until ${CUTOFF}.`,
  'dying-soon':
    'retrain onto the supported base before the date. Where the fine-tune only ever '
    + 'encoded formatting, evaluate replacing it with prompting plus structured '
    + 'outputs instead of retraining at all.',
  'no-base-date':
    'neither the model object nor the published table has a date for this base, so '
    + 'its serving deadline is unknown. Treat it as undated rather than as safe.',
};

const day = (iso) => Date.parse(`${iso}T00:00:00Z`);

/** Whole days from today to a date. Pure. Negative once it has passed. */
export function daysLeft(today, when) {
  return Math.round((day(String(when)) - day(String(today))) / 86400000);
}

/** Can this organization still create a job? Pure. [state, detail]. */
export function createEligibility(today, hasPriorJobs, daysSinceFtInference) {
  if (daysLeft(today, CUTOFF) < 0) {
    return ['create-closed',
      `the ${CUTOFF} cutoff has passed, so no organization can create a fine-tuning job`];
  }
  if (!hasPriorJobs && daysLeft(today, NEVER_FINE_TUNED) < 0) {
    return ['blocked-never-fine-tuned',
      `the job list is empty and the ${NEVER_FINE_TUNED} restriction has passed, so `
      + 'this organization cannot create a job today. Read from the listing, not '
      + 'from an attempt'];
  }
  if (daysLeft(today, NO_RECENT_INFERENCE) >= 0) {
    return ['eligible',
      `the 60 day inference rule does not apply until ${NO_RECENT_INFERENCE}; `
      + `${daysLeft(today, CUTOFF)} day(s) until the ${CUTOFF} cutoff`];
  }
  if (daysSinceFtInference === null || daysSinceFtInference === undefined) {
    return ['unknown-eligibility',
      'the inference clock could not be read, so eligibility is unknown rather than fine'];
  }
  if (daysSinceFtInference === 'none-in-window') {
    return ['blocked-no-recent-inference',
      'no fine-tuned model produced a request anywhere in the window read, so the '
      + `${WINDOW} day rule has already closed creation. Read from usage, not from `
      + 'an attempt'];
  }
  const days = Number(daysSinceFtInference);
  if (days > WINDOW) {
    return ['blocked-no-recent-inference',
      `no fine-tuned model has served a request for ${days} day(s), and the ${WINDOW} `
      + `day rule has applied since ${NO_RECENT_INFERENCE}, so new jobs are already `
      + 'being refused. Read from usage, not from an attempt'];
  }
  if (days > 45) {
    return ['eligibility-expiring',
      `the last fine-tuned request was ${days} day(s) ago, so ${WINDOW - days} day(s) `
      + `of the ${WINDOW} day window are left`];
  }
  return ['eligible',
    `the last fine-tuned request was ${days} day(s) ago and ${daysLeft(today, CUTOFF)} `
    + `day(s) remain until the ${CUTOFF} cutoff`];
}

/** The deprecation family and replacement for a base. Pure. [family, to]. */
export function familyFor(baseModel) {
  const base = String(baseModel ?? '');
  for (const [prefix, family, replacement] of FAMILIES) {
    if (base === prefix || base.startsWith(`${prefix}-`)) return [family, replacement];
  }
  return [null, null];
}

/** When this fine-tune stops serving. Pure. [date, source, detail]. */
export function servingDeadline(apiShutdownDate, family) {
  if (apiShutdownDate) {
    return [String(apiShutdownDate), 'api', 'shutdown_date read off the model object'];
  }
  if (family) {
    return [BASE_SHUTDOWN, 'published-table',
      `the model object carried no shutdown_date, so this is the ${family} row in `
      + 'the deprecation table'];
  }
  return [null, 'unknown',
    'neither the model object nor the published table has a date for this base'];
}

/** Grade one job's serving half. Pure. [state, detail]. */
export function jobVerdict(status, fineTunedModel, deadline, today) {
  if (String(status) !== 'succeeded' || !fineTunedModel) {
    return ['not-serving',
      `status ${status} with no fine-tuned model, so nothing is serving from this job`];
  }
  if (!deadline) {
    return ['no-base-date', 'no serving deadline could be established for this base'];
  }
  const left = daysLeft(today, deadline);
  if (left < 0) {
    return ['already-dead',
      `the base shut down ${-left} day(s) ago, so this fine-tune has stopped serving`];
  }
  if (left <= 90) return ['dying-soon', `${left} day(s) of inference left`];
  return ['serving', `${left} day(s) of inference left`];
}

/** The repair for one verdict. Pure. Printed, never performed. */
export function repairLines(state, replacement = null) {
  const line = REPAIRS[state];
  if (!line) return [];
  if ((state === 'dying-soon' || state === 'already-dead') && replacement) {
    return [line, `the documented replacement base is ${replacement}.`];
  }
  if (state === 'blocked-no-recent-inference') {
    return [line,
      `note the order the dates fall in: the bases die ${BASE_SHUTDOWN} and the right `
      + `to retrain closes ${CUTOFF}, so October is the deadline and January is only `
      + 'the outside edge.'];
  }
  return [line];
}

async function getJson(path, key, params = {}) {
  const url = new URL(API + path);
  for (const [k, v] of Object.entries(params)) {
    for (const one of Array.isArray(v) ? v : [v]) url.searchParams.append(k, String(one));
  }
  try {
    const r = await fetch(url, { headers: { Authorization: `Bearer ${key}` } });
    let body = {};
    try { body = await r.json(); } catch { body = {}; }
    return [r.status, body];
  } catch {
    return [null, {}];
  }
}

async function allJobs(key, pages = 20) {
  const out = [];
  let after = null;
  for (let i = 0; i < pages; i += 1) {
    const params = { limit: 100 };
    if (after) params.after = after;
    const [status, body] = await getJson('/fine_tuning/jobs', key, params);
    if (status !== 200) {
      console.log(`job listing came back ${status}, so eligibility cannot be read from it`);
      break;
    }
    const page = body.data || [];
    out.push(...page);
    if (!page.length || !body.has_more) break;
    after = page[page.length - 1].id;
    if (!after) break;
  }
  return out;
}

async function daysSinceFtInference(key, today, days = 70) {
  const start = Math.floor(Date.now() / 1000) - days * 86400;
  const [status, body] = await getJson('/organization/usage/completions', key, {
    start_time: start, bucket_width: '1d', 'group_by[]': ['model'], limit: 180,
  });
  if (status !== 200) {
    console.log(`usage report came back ${status}, so the inference clock could not be read`);
    return null;
  }
  let last = null;
  for (const bucket of body.data || []) {
    if (!bucket.start_time) continue;
    const d = new Date(bucket.start_time * 1000).toISOString().slice(0, 10);
    for (const row of bucket.results || []) {
      const model = String(row.model ?? '');
      if (model.startsWith('ft:') && (row.num_model_requests || 0) > 0) {
        last = last === null || d > last ? d : last;
      }
    }
  }
  if (last === null) return 'none-in-window';
  return -daysLeft(today, last);
}

async function main() {
  const key = process.env.OPENAI_API_KEY;
  if (!key) {
    console.error('set OPENAI_API_KEY to a project read key. This script only issues '
                  + 'GET requests and never submits a job');
    process.exitCode = 2;
    return;
  }
  const today = process.env.TODAY || new Date().toISOString().slice(0, 10);
  let findings = 0;

  const jobs = await allJobs(key);
  const admin = process.env.OPENAI_ADMIN_KEY;
  const since = admin ? await daysSinceFtInference(admin, today) : null;
  const sinceText = since === null ? 'unknown'
    : since === 'none-in-window' ? 'not in the window' : `${since} day(s) ago`;
  console.log(`create: ${jobs.length} job(s) in the list, last ft: inference ${sinceText}`);

  const [state, detail] = createEligibility(today, jobs.length > 0, since);
  console.log(`${state.padEnd(28)} ${detail}`);
  for (const line of repairLines(state)) console.log(`  repair: ${line}`);
  if (FINDINGS.has(state)) findings += 1;

  const succeeded = jobs.filter((j) => String(j.status) === 'succeeded' && j.fine_tuned_model);
  console.log(`serve: ${succeeded.length} succeeded job(s)`);
  for (const job of succeeded) {
    const ftm = job.fine_tuned_model;
    const base = job.model;
    const [family, replacement] = familyFor(base);
    const [, ftmBody] = await getJson(`/models/${ftm}`, key);
    let shutdown = ftmBody.shutdown_date;
    if (!shutdown && base) {
      const [, baseBody] = await getJson(`/models/${base}`, key);
      shutdown = baseBody.shutdown_date;
    }
    const [deadline, source] = servingDeadline(shutdown, family);
    const [jstate, jdetail] = jobVerdict(job.status, ftm, deadline, today);
    console.log(`  ${String(ftm).padEnd(40)} ${(deadline || '---').padEnd(11)} `
                + `${source.padEnd(16)} ${jstate.padEnd(13)} ${jdetail}`);
    for (const line of repairLines(jstate, replacement)) console.log(`    repair: ${line}`);
    if (FINDINGS.has(jstate)) findings += 1;
  }

  console.log(`${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 constraint made mechanical: a blocked verdict is reached from three readable inputs — a date, a non-empty job list, and days since the last ft: request — and the detail says read from usage, not from an attempt, because the alternative way to answer this question is to submit a job. Then the ordering trap: gpt-4.1-nano-2025-04-14 begins with the characters gpt-4 and must not come back as ft-gpt-4 with gpt-5.6-sol beside it, and a base the table does not cover comes back as nothing rather than as the nearest row. Then the evidence label, which distinguishes a shutdown_date the API stated from the published table's fallback. Then the two verbs held apart: a fine-tune that is still serving healthily alongside an organization that can no longer create, which is the pair of readings the note exists to produce. And finally the three shapes of the inference clock, including the one where it could not be read at all, which must come back as unknown rather than as eligible.

test_fine_tuning_gate_audit.py
from fine_tuning_gate_audit import (BASE_SHUTDOWN, CUTOFF, WINDOW,
                                    create_eligibility, days_left, family_for,
                                    job_verdict, repair_lines,
                                    serving_deadline)

TODAY = "2026-08-31"


def test_a_blocked_verdict_comes_from_readable_state_and_not_an_attempt():
    # The whole constraint, made mechanical. Three inputs, all readable with
    # the keys this script already holds, and no job is ever submitted.
    state, detail = create_eligibility(TODAY, True, 63)
    assert state == "blocked-no-recent-inference"
    assert "63 day(s)" in detail
    assert "Read from usage, not from an attempt" in detail
    lines = repair_lines(state)
    assert any("route real traffic" in line for line in lines)
    assert any(BASE_SHUTDOWN in line and CUTOFF in line for line in lines)

    state, detail = create_eligibility(TODAY, False, 3)
    assert state == "blocked-never-fine-tuned"
    assert "Read from the listing, not from an attempt" in detail


def test_the_window_closing_is_its_own_state_with_the_days_left_in_it():
    state, detail = create_eligibility(TODAY, True, 52)
    assert state == "eligibility-expiring"
    assert "%d day(s)" % (WINDOW - 52) in detail
    assert create_eligibility(TODAY, True, 12)[0] == "eligible"
    assert create_eligibility("2027-02-01", True, 1)[0] == "create-closed"
    # Before the middle rule applied, recent inference was irrelevant.
    assert create_eligibility("2026-06-01", True, 400)[0] == "eligible"


def test_the_three_shapes_of_the_inference_clock():
    assert create_eligibility(TODAY, True, "none-in-window")[0] == \
        "blocked-no-recent-inference"
    state, detail = create_eligibility(TODAY, True, None)
    assert state == "unknown-eligibility"
    assert "unknown rather than fine" in detail
    assert any("admin-read key" in line for line in repair_lines(state))


def test_a_base_is_matched_exactly_or_on_a_hyphen_and_never_loosely():
    # gpt-4.1-nano-2025-04-14 starts with the characters gpt-4. Filing it under
    # ft-gpt-4 would print gpt-5.6-sol with complete confidence.
    family, replacement = family_for("gpt-4.1-nano-2025-04-14")
    assert family == "ft-gpt-4.1-nano-2025-04-14"
    assert replacement == "gpt-5.6-luna"
    assert family_for("gpt-4")[0] == "ft-gpt-4"
    assert family_for("gpt-4-0613") == ("ft-gpt-4", "gpt-5.6-sol")
    assert family_for("gpt-3.5-turbo-0125")[1] == "gpt-5.6-terra"
    # A base the table does not cover is unknown, not the nearest-looking row.
    assert family_for("gpt-4o-mini-2024-07-18") == (None, None)
    assert family_for(None) == (None, None)


def test_a_date_from_the_api_is_labelled_apart_from_the_published_table():
    date, source, why = serving_deadline("2026-12-01", "ft-gpt-4")
    assert (date, source) == ("2026-12-01", "api")
    assert "read off the model object" in why

    date, source, why = serving_deadline(None, "ft-gpt-4")
    assert (date, source) == (BASE_SHUTDOWN, "published-table")
    assert "ft-gpt-4 row in the deprecation table" in why

    date, source, _ = serving_deadline(None, None)
    assert (date, source) == (None, "unknown")


def test_the_two_verbs_are_graded_apart_and_can_disagree():
    # The pair this note exists to produce: creation already refused while a
    # fine-tune is serving perfectly well for months yet.
    create, _ = create_eligibility(TODAY, True, 63)
    serve, detail = job_verdict("succeeded", "ft:gpt-4:acme::Ab12",
                                "2027-06-01", TODAY)
    assert create == "blocked-no-recent-inference"
    assert serve == "serving"
    assert "day(s) of inference left" in detail

    state, detail = job_verdict("succeeded", "ft:gpt-4:acme::Ab12",
                                BASE_SHUTDOWN, TODAY)
    assert state == "dying-soon"
    assert "53 day(s)" in detail
    lines = repair_lines(state, "gpt-5.6-sol")
    assert any("gpt-5.6-sol" in line for line in lines)
    assert any("structured outputs" in line for line in lines)


def test_a_job_with_nothing_serving_and_a_base_with_no_date():
    assert job_verdict("failed", None, BASE_SHUTDOWN, TODAY)[0] == "not-serving"
    assert job_verdict("succeeded", None, BASE_SHUTDOWN, TODAY)[0] == "not-serving"
    state, _ = job_verdict("succeeded", "ft:x:acme::Zz99", None, TODAY)
    assert state == "no-base-date"
    assert any("undated rather than as safe" in line
               for line in repair_lines(state))
    assert job_verdict("succeeded", "ft:x:acme::Zz99", "2026-08-01",
                       TODAY)[0] == "already-dead"
    assert days_left(TODAY, BASE_SHUTDOWN) == 53
fine-tuning-gate-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { BASE_SHUTDOWN, CUTOFF, WINDOW, createEligibility, daysLeft, familyFor,
         jobVerdict, repairLines, servingDeadline } from './fine-tuning-gate-audit.mjs';

const TODAY = '2026-08-31';

test('a blocked verdict comes from readable state and not an attempt', () => {
  let [state, detail] = createEligibility(TODAY, true, 63);
  assert.equal(state, 'blocked-no-recent-inference');
  assert.ok(detail.includes('63 day(s)'));
  assert.ok(detail.includes('Read from usage, not from an attempt'));
  const lines = repairLines(state);
  assert.ok(lines.some((l) => l.includes('route real traffic')));
  assert.ok(lines.some((l) => l.includes(BASE_SHUTDOWN) && l.includes(CUTOFF)));

  [state, detail] = createEligibility(TODAY, false, 3);
  assert.equal(state, 'blocked-never-fine-tuned');
  assert.ok(detail.includes('Read from the listing, not from an attempt'));
});

test('the window closing is its own state with the days left in it', () => {
  const [state, detail] = createEligibility(TODAY, true, 52);
  assert.equal(state, 'eligibility-expiring');
  assert.ok(detail.includes(`${WINDOW - 52} day(s)`));
  assert.equal(createEligibility(TODAY, true, 12)[0], 'eligible');
  assert.equal(createEligibility('2027-02-01', true, 1)[0], 'create-closed');
  assert.equal(createEligibility('2026-06-01', true, 400)[0], 'eligible');
});

test('the three shapes of the inference clock', () => {
  assert.equal(createEligibility(TODAY, true, 'none-in-window')[0],
               'blocked-no-recent-inference');
  const [state, detail] = createEligibility(TODAY, true, null);
  assert.equal(state, 'unknown-eligibility');
  assert.ok(detail.includes('unknown rather than fine'));
  assert.ok(repairLines(state).some((l) => l.includes('admin-read key')));
});

test('a base is matched exactly or on a hyphen and never loosely', () => {
  const [family, replacement] = familyFor('gpt-4.1-nano-2025-04-14');
  assert.equal(family, 'ft-gpt-4.1-nano-2025-04-14');
  assert.equal(replacement, 'gpt-5.6-luna');
  assert.equal(familyFor('gpt-4')[0], 'ft-gpt-4');
  assert.deepEqual(familyFor('gpt-4-0613'), ['ft-gpt-4', 'gpt-5.6-sol']);
  assert.equal(familyFor('gpt-3.5-turbo-0125')[1], 'gpt-5.6-terra');
  assert.deepEqual(familyFor('gpt-4o-mini-2024-07-18'), [null, null]);
  assert.deepEqual(familyFor(null), [null, null]);
});

test('a date from the api is labelled apart from the published table', () => {
  let [date, source, why] = servingDeadline('2026-12-01', 'ft-gpt-4');
  assert.equal(date, '2026-12-01');
  assert.equal(source, 'api');
  assert.ok(why.includes('read off the model object'));

  [date, source, why] = servingDeadline(null, 'ft-gpt-4');
  assert.equal(date, BASE_SHUTDOWN);
  assert.equal(source, 'published-table');
  assert.ok(why.includes('ft-gpt-4 row in the deprecation table'));

  [date, source] = servingDeadline(null, null);
  assert.equal(date, null);
  assert.equal(source, 'unknown');
});

test('the two verbs are graded apart and can disagree', () => {
  const [create] = createEligibility(TODAY, true, 63);
  let [serve, detail] = jobVerdict('succeeded', 'ft:gpt-4:acme::Ab12', '2027-06-01', TODAY);
  assert.equal(create, 'blocked-no-recent-inference');
  assert.equal(serve, 'serving');
  assert.ok(detail.includes('day(s) of inference left'));

  [serve, detail] = jobVerdict('succeeded', 'ft:gpt-4:acme::Ab12', BASE_SHUTDOWN, TODAY);
  assert.equal(serve, 'dying-soon');
  assert.ok(detail.includes('53 day(s)'));
  const lines = repairLines(serve, 'gpt-5.6-sol');
  assert.ok(lines.some((l) => l.includes('gpt-5.6-sol')));
  assert.ok(lines.some((l) => l.includes('structured outputs')));
});

test('a job with nothing serving and a base with no date', () => {
  assert.equal(jobVerdict('failed', null, BASE_SHUTDOWN, TODAY)[0], 'not-serving');
  assert.equal(jobVerdict('succeeded', null, BASE_SHUTDOWN, TODAY)[0], 'not-serving');
  const [state] = jobVerdict('succeeded', 'ft:x:acme::Zz99', null, TODAY);
  assert.equal(state, 'no-base-date');
  assert.ok(repairLines(state).some((l) => l.includes('undated rather than as safe')));
  assert.equal(jobVerdict('succeeded', 'ft:x:acme::Zz99', '2026-08-01', TODAY)[0],
               'already-dead');
  assert.equal(daysLeft(TODAY, BASE_SHUTDOWN), 53);
});

FAQ

How is this different from the note about a fine-tuned model nobody calls?

Same number, opposite meaning. That note reads zero traffic on a trained model as waste: you paid for training and never routed to it, so either route or retire. Here zero ft: traffic is an eligibility clock. Since 2 July 2026 an organization that has not run inference on a fine-tuned model in the past 60 days can no longer create a job, so the traffic that note tells you to consider stopping is the thing keeping your right to retrain alive. Read both before you retire anything.

Why not just try creating a job and see whether it is refused?

Because that is a write, it costs money, and it trains a model nobody asked for. Every script in this section is read-only, and a note about an endpoint that stopped accepting work has no business submitting work to find out. The eligibility function takes three readable inputs — today's date, whether the job list is non-empty, and days since the last ft: request — and there is a test asserting that a blocked verdict is reached from those alone.

Which deadline should I actually plan around?

October, not January. Every fine-tunable base in the deprecation table shuts down on 23 October 2026, and inference on a fine-tune dies with its base, so your models stop serving then. The 6 January 2027 date is when creating new jobs ends for everyone — the outside edge of the window in which you can react, not the schedule. An organization that notices the first date in October has roughly ten weeks to retrain everything worth keeping, once.

The script says published-table rather than api for my model. Does that matter?

It tells you how strong the claim is. Where GET /v1/models/{id} returns a shutdown_date, the API stated it and that is authoritative. Where it does not, the only source is the deprecation table's family row, which is a document and documents lag. Both produce the same date today; only one of them will update itself if the schedule changes. Labelling them separately is the same discipline the rest of this section applies to anything it inferred rather than measured.

My base model is not in any of the six families. What then?

The script reports the family as unknown and the serving deadline as no-base-date, rather than filing it under the nearest-looking row. Matching is exact or hyphen-delimited on purpose: gpt-4.1-nano-2025-04-14 begins with the characters gpt-4, and a loose prefix match would print the wrong replacement with total confidence. An undated base is not a safe base — it means neither source has a date for it, and you should establish one before assuming it outlives October.

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.