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.
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.
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.
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.
"""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())
/**
* 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.
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
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
- The other half of the lifecycle: trained, paid for, never called
- A date already passed, for a model id rather than a path
- Three more surfaces closing, where the content is the thing at risk
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.
- Deprecations — OpenAI platform docs
- Fine-tuning jobs — OpenAI API reference
- Retrieve model, including the shutdown_date field
- Usage, completions — OpenAI API reference
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.