Diagnostic LLM APIs
a model you still call retires in under 90 days
Every call is returning 200. Latency is normal, cost is normal, the evals are green. The only thing wrong is a field nobody is reading: shutdown_date on the model you route most of your traffic to is a real date, and it is close. Nothing will change until that morning, and then everything will, all at once, in every code path that names the id.
GET /v1/models, read shutdown_date on each entry, and flag the ones that are non-null and less than 90 days out. That is the window where you can still do this on a Tuesday afternoon instead of during an incident.
With an admin-read key, join it against GET /v1/organization/usage/completions?bucket_width=1d&group_by[]=model so the list is ordered by how much traffic each id actually carries. A model with a date in six weeks and four million requests is a different piece of work from one with the same date and zero.
The problem in plain words
The hard part of a model migration is never the string. It is finding out about it early enough that the swap can be tested, evaluated and rolled out normally, rather than pasted in at 07:00 by whoever answered the page. Nothing in the request path tells you it is coming, so the deadline arrives through email, chat, or a colleague who happened to read a changelog — three channels that all fail quietly.
The other half is that "we should migrate" is not actionable until it has a size. Which ids? How much traffic on each? Which are load-bearing and which are strings left in a config file from an experiment two quarters ago? Without that, the work gets deferred at every planning meeting because nobody can say how big it is, and the deferral holds right up to the day it cannot.
Why it happens
The field is there, but only if something reads it. shutdown_date is on every entry in the models list, which means the deadline is machine-readable and free to check. It is also entirely passive: no header, no warning on a successful response, nothing in an SDK exception, because there is no exception. A check that never runs is indistinguishable from a field that does not exist.
Days remaining is only half of the priority. Two ids with the same date can be a week of work and five minutes of work. The traffic split lives on the Admin usage endpoints, not on the models list, and it needs a different credential — an organization admin key, because usage belongs to the organization rather than to a project. The script treats that key as optional and says so when it is missing, rather than pretending the ordering is complete.
Zero traffic on a dated id is a finding, not a pass. An id with a shutdown date and no requests in the last 30 days is usually a string in a config file, a fallback branch, or a batch job that runs monthly. The first two want deleting, the third is a landmine with a fuse a month long. What none of them wants is to be filtered out of the report as "unused".
The other provider does not give you this field at all. Anthropic publishes retirement dates on its deprecations page and exposes none of them through the API: the model object has created_at, max_input_tokens and max_output_tokens, and no date. So the same check on that side is a join against a table you maintain by hand, and the API's only contribution is whether the id is still callable.
The fix, as a flow
The date comes from the models list and the traffic comes from the organization usage endpoint, on a different credential. The finding is the join: a deadline without a size gets deferred at every planning meeting until it cannot be.
How to fix it
Read the models list and keep the non-null dates
GET https://api.openai.com/v1/models with a Read Only project key. Most entries have shutdown_date null; the ones that do not are your calendar.
Set a window that matches how long a migration takes you
Ninety days is a reasonable default because it is roughly the notice period, but the number that matters is your own: evaluation, canary and rollout for a model change. Make it an argument. A team that needs six weeks and a team that needs one should not be reading the same report.
Join the dates against traffic
With an organization admin key: GET /v1/organization/usage/completions?start_time=<30d ago>&bucket_width=1d&group_by[]=model, then sum data[].results[].num_model_requests per results[].model. Sort the flagged ids by that number so the biggest migration is on the first line.
Treat an urgent date differently from a due one
Under a month left is not the same status as under three. The first is scheduling work now; the second is putting it in the next cycle. Two states, printed differently, or the report reads as one undifferentiated wall and gets skimmed.
Pin the successor and re-run the check on it
The replacement has a shutdown date too, or will have. The migration is finished when the new id is pinned and the check has been run against it, so the next deadline is already visible rather than waiting to be discovered the same way.
How to check it worked
Re-run after the migration. The window should be empty, and the ids you moved to should report a date well outside it, or none.
python3 openai_model_retirement_window.py --window 90
# 41 dated model(s), 0 inside a 90 day window
The full code
Two GETs and no writes. The models list needs only a Read Only project key; the traffic join needs an organization admin key and is skipped, loudly, when there is not one. The classifier takes both the date to measure from and the window, so the thresholds are arguments rather than constants — which is the only honest way to write a rule whose right answer depends on how long your own rollout takes.
"""Turn OpenAI shutdown dates into a migration schedule, ordered by urgency.
Read only. GET requests and nothing else: the models list needs a project key
set to Read Only, and the optional traffic join needs an organization admin key
because usage belongs to the organization rather than to a project. The repair
is printed, never performed.
"""
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("openai_model_retirement_window")
API = "https://api.openai.com/v1"
FLAGGED = ("urgent", "due", "expired", "unreadable-date")
def parse_day(value):
"""Read a shutdown_date into a date, or None when it cannot be read."""
raw = str(value or "").strip()
if not raw:
return None
try:
return dt.date.fromisoformat(raw.split("T")[0])
except ValueError:
return None
def traffic_note(requests_30d):
"""How the traffic column is described, including when there is none.
None means the admin key was not supplied, which is different from zero and
has to read differently, or an unmeasured id looks like an unused one.
"""
if requests_30d is None:
return ("traffic unknown: no admin key, so this is ordered by date "
"alone")
if requests_30d == 0:
return ("no requests in the last 30 days, so this is probably a string "
"in a config file or a monthly job rather than live traffic")
return "%d request(s) in the last 30 days" % (requests_30d,)
def plan(model, today, window_days=90, urgent_within=30, requests_30d=None):
"""Classify one models-list entry into a place in the migration schedule.
Pure, and both thresholds are arguments: the right window is however long a
model change takes to evaluate and roll out where you work, and that is not
a constant this script gets to choose. Returns (state, detail).
"""
raw = model.get("shutdown_date")
if raw is None or str(raw).strip() == "":
return ("unscheduled",
"no shutdown date published today. Re-read the field rather "
"than trusting this answer for a quarter.")
day = parse_day(raw)
if day is None:
return ("unreadable-date",
"shutdown_date is %r, which this script will not guess at."
% (raw,))
days = (day - today).days
note = traffic_note(requests_30d)
if days < 0:
return ("expired",
"shut down %d day(s) ago on %s. This is past planning; calls "
"naming it are already failing. %s"
% (-days, day.isoformat(), note))
if days <= urgent_within:
return ("urgent",
"%d day(s) left, shutting down %s. Under %d days is scheduling "
"work now, not next cycle. %s"
% (days, day.isoformat(), urgent_within, note))
if days <= window_days:
return ("due",
"%d day(s) left, shutting down %s. Inside the %d day window. %s"
% (days, day.isoformat(), window_days, note))
return ("later",
"%d day(s) left, shutting down %s. Outside the window; nothing to "
"do yet. %s" % (days, day.isoformat(), note))
def get(session, path, **params):
r = session.get(API + path, params=params, timeout=60)
if r.status_code in (401, 403):
raise SystemExit("%d from OpenAI on %s: check the key, and that an "
"organization admin key is used for /organization/*"
% (r.status_code, path))
r.raise_for_status()
return r.json()
def usage_by_model(admin_key, days):
"""Sum num_model_requests per model over the window.
Needs an organization admin key: the usage endpoints reject project keys
outright. Returns {} when no key was given, which the caller reports as
unknown rather than as zero.
"""
if not admin_key:
return {}
session = requests.Session()
session.headers.update({"Authorization": "Bearer " + admin_key})
start = int((dt.datetime.now(dt.timezone.utc)
- dt.timedelta(days=days)).timestamp())
totals = {}
params = {"start_time": start, "bucket_width": "1d",
"group_by[]": "model", "limit": 31}
while True:
page = get(session, "/organization/usage/completions", **params)
for bucket in page.get("data", []):
for row in bucket.get("results", []):
name = row.get("model")
if name:
totals[name] = totals.get(name, 0) + int(
row.get("num_model_requests") or 0)
if not page.get("has_more"):
break
params["page"] = page.get("next_page")
if not params["page"]:
break
return totals
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--window", type=int, default=90,
help="days ahead to treat as inside the migration window")
ap.add_argument("--urgent-within", type=int, default=30,
help="days ahead that count as urgent rather than due")
ap.add_argument("--usage-days", type=int, default=30,
help="days of usage to sum when an admin key is available")
args = ap.parse_args()
key = os.environ.get("OPENAI_API_KEY")
if not key:
log.error("set OPENAI_API_KEY (a project key set to Read Only)")
return 2
admin = os.environ.get("OPENAI_ADMIN_KEY")
if not admin:
log.warning("OPENAI_ADMIN_KEY is not set: the report will be ordered by "
"date alone, with no idea which ids carry traffic")
session = requests.Session()
session.headers.update({"Authorization": "Bearer " + key})
models = get(session, "/models").get("data", [])
dated = [m for m in models if str(m.get("shutdown_date") or "").strip()]
totals = usage_by_model(admin, args.usage_days)
rows = []
today = dt.date.today()
for model in dated:
model_id = str(model.get("id") or "?")
seen = totals.get(model_id) if admin else None
if admin and seen is None:
seen = 0
state, detail = plan(model, today, args.window, args.urgent_within, seen)
rows.append((parse_day(model.get("shutdown_date")) or dt.date.max,
-(seen or 0), state, model_id, detail))
flagged = 0
for _day, _neg, state, model_id, detail in sorted(rows):
line = "%-14s %s %s" % (state, model_id, detail)
if state in FLAGGED:
flagged += 1
log.warning(line)
log.warning(" repair: pin the successor from the deprecations page, "
"then re-run this against the new id so its own date is "
"on the calendar before it is a surprise")
else:
log.info(line)
log.info("%d dated model(s), %d inside a %d day window",
len(dated), flagged, args.window)
return 1 if flagged else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Turn OpenAI shutdown dates into a migration schedule, ordered by urgency.
*
* Read only. GET requests and nothing else: the models list needs a project key
* set to Read Only, and the optional traffic join needs an organization admin
* key. The repair is printed, never performed.
*/
const API = 'https://api.openai.com/v1';
const DAY = 86400000;
const FLAGGED = ['urgent', 'due', 'expired', 'unreadable-date'];
/** Read a shutdown_date into a UTC date, or null when it cannot be read. */
export function parseDay(value) {
const raw = String(value ?? '').trim().split('T')[0];
if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) return null;
const ms = Date.parse(`${raw}T00:00:00Z`);
return Number.isNaN(ms) ? null : new Date(ms);
}
/**
* How the traffic column is described, including when there is none. null means
* the admin key was not supplied, which is different from zero and has to read
* differently, or an unmeasured id looks like an unused one.
*/
export function trafficNote(requests30d) {
if (requests30d === null || requests30d === undefined) {
return 'traffic unknown: no admin key, so this is ordered by date alone';
}
if (requests30d === 0) {
return 'no requests in the last 30 days, so this is probably a string in a ' +
'config file or a monthly job rather than live traffic';
}
return `${requests30d} request(s) in the last 30 days`;
}
/**
* Classify one models-list entry into a place in the migration schedule. Pure,
* and both thresholds are arguments: the right window is however long a model
* change takes to evaluate and roll out where you work. Returns [state, detail].
*/
export function plan(model, today, windowDays = 90, urgentWithin = 30,
requests30d = null) {
const raw = model.shutdown_date;
if (raw === null || raw === undefined || String(raw).trim() === '') {
return ['unscheduled',
'no shutdown date published today. Re-read the field rather than ' +
'trusting this answer for a quarter.'];
}
const day = parseDay(raw);
if (day === null) {
return ['unreadable-date',
`shutdown_date is ${JSON.stringify(raw)}, which this script will not guess at.`];
}
const iso = day.toISOString().slice(0, 10);
const days = Math.round((day.getTime() - today.getTime()) / DAY);
const note = trafficNote(requests30d);
if (days < 0) {
return ['expired',
`shut down ${-days} day(s) ago on ${iso}. This is past planning; calls ` +
`naming it are already failing. ${note}`];
}
if (days <= urgentWithin) {
return ['urgent',
`${days} day(s) left, shutting down ${iso}. Under ${urgentWithin} days is ` +
`scheduling work now, not next cycle. ${note}`];
}
if (days <= windowDays) {
return ['due',
`${days} day(s) left, shutting down ${iso}. Inside the ${windowDays} day ` +
`window. ${note}`];
}
return ['later',
`${days} day(s) left, shutting down ${iso}. Outside the window; nothing to ` +
`do yet. ${note}`];
}
async function get(key, path, params = {}) {
const url = new URL(API + path);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const res = await fetch(url, { headers: { Authorization: `Bearer ${key}` } });
if (res.status === 401 || res.status === 403) {
throw new Error(`${res.status} from OpenAI on ${path}: check the key, and ` +
'that an organization admin key is used for /organization/*');
}
if (!res.ok) throw new Error(`${res.status} from ${path}`);
return res.json();
}
export async function usageByModel(adminKey, days) {
if (!adminKey) return new Map();
const start = Math.floor((Date.now() - days * DAY) / 1000);
const totals = new Map();
const params = { start_time: start, bucket_width: '1d',
'group_by[]': 'model', limit: 31 };
for (;;) {
const page = await get(adminKey, '/organization/usage/completions', params);
for (const bucket of page.data ?? []) {
for (const row of bucket.results ?? []) {
if (!row.model) continue;
totals.set(row.model,
(totals.get(row.model) ?? 0) + Number(row.num_model_requests ?? 0));
}
}
if (!page.has_more || !page.next_page) break;
params.page = page.next_page;
}
return totals;
}
async function main() {
const key = process.env.OPENAI_API_KEY;
if (!key) {
console.error('set OPENAI_API_KEY (a project key set to Read Only)');
process.exitCode = 2;
return;
}
const admin = process.env.OPENAI_ADMIN_KEY;
if (!admin) {
console.warn('OPENAI_ADMIN_KEY is not set: the report will be ordered by ' +
'date alone, with no idea which ids carry traffic');
}
const arg = (name, fallback) => Number(process.argv.includes(name)
? process.argv[process.argv.indexOf(name) + 1] : fallback) || fallback;
const windowDays = arg('--window', 90);
const urgentWithin = arg('--urgent-within', 30);
const usageDays = arg('--usage-days', 30);
const { data = [] } = await get(key, '/models');
const dated = data.filter((m) => String(m.shutdown_date ?? '').trim() !== '');
const totals = await usageByModel(admin, usageDays);
const today = new Date(`${new Date().toISOString().slice(0, 10)}T00:00:00Z`);
const rows = dated.map((model) => {
const modelId = String(model.id ?? '?');
const seen = admin ? (totals.get(modelId) ?? 0) : null;
const [state, detail] = plan(model, today, windowDays, urgentWithin, seen);
const day = parseDay(model.shutdown_date);
return { sort: day ? day.getTime() : Infinity, seen: seen ?? 0,
state, modelId, detail };
}).sort((a, b) => a.sort - b.sort || b.seen - a.seen);
let flagged = 0;
for (const row of rows) {
const line = `${row.state.padEnd(14)} ${row.modelId} ${row.detail}`;
if (FLAGGED.includes(row.state)) {
flagged += 1;
console.warn(line);
console.warn(' repair: pin the successor from the deprecations page, then ' +
're-run this against the new id so its own date is on the calendar ' +
'before it is a surprise');
} else {
console.log(line);
}
}
console.log(`${dated.length} dated model(s), ${flagged} inside a ${windowDays} day window`);
process.exitCode = flagged ? 1 : 0;
}
// Only run when invoked directly. The test file imports this module, and without
// the guard main() would run there too, fail on the missing key, and set a
// non-zero exit code that fails the whole test file even as every test passes.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
The tests fix the date and then move the thresholds, because both are arguments and the whole value of the note is in where the lines fall. The one to read is the traffic case: an id with no admin key behind it and an id with genuinely zero requests must not produce the same sentence, or a model nobody measured gets filed as a model nobody uses.
import datetime as dt
from openai_model_retirement_window import plan, traffic_note
TODAY = dt.date(2026, 8, 30)
def dated(day, model_id="gpt-5-2025-08-07"):
return {"id": model_id, "shutdown_date": day}
def test_a_date_inside_the_window_is_due():
state, detail = plan(dated("2026-11-15"), TODAY)
assert state == "due"
assert "77 day(s) left" in detail
def test_a_date_under_a_month_out_is_urgent_not_merely_due():
state, detail = plan(dated("2026-09-20"), TODAY)
assert state == "urgent"
assert "not next cycle" in detail
def test_a_date_beyond_the_window_is_left_alone():
assert plan(dated("2027-06-01"), TODAY)[0] == "later"
def test_the_window_and_the_urgency_line_are_both_arguments():
model = dated("2026-11-15")
assert plan(model, TODAY)[0] == "due"
assert plan(model, TODAY, window_days=30)[0] == "later"
assert plan(model, TODAY, window_days=90, urgent_within=120)[0] == "urgent"
def test_a_date_already_passed_is_out_of_scope_for_planning():
state, detail = plan(dated("2026-07-01"), TODAY)
assert state == "expired"
assert "already failing" in detail
def test_no_date_is_unscheduled_rather_than_safe():
state, detail = plan({"id": "gpt-5.6-sol"}, TODAY)
assert state == "unscheduled"
assert "Re-read" in detail
assert plan({"id": "x", "shutdown_date": "Q4"}, TODAY)[0] == "unreadable-date"
def test_unmeasured_traffic_and_zero_traffic_do_not_read_the_same():
assert "no admin key" in traffic_note(None)
assert "config file" in traffic_note(0)
assert "4000000 request(s)" in traffic_note(4000000)
assert "config file" in plan(dated("2026-09-20"), TODAY, requests_30d=0)[1]
assert "no admin key" in plan(dated("2026-09-20"), TODAY)[1]
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { plan, trafficNote } from './openai-model-retirement-window.mjs';
const TODAY = new Date('2026-08-30T00:00:00Z');
const dated = (day, id = 'gpt-5-2025-08-07') => ({ id, shutdown_date: day });
test('a date inside the window is due', () => {
const [state, detail] = plan(dated('2026-11-15'), TODAY);
assert.equal(state, 'due');
assert.match(detail, /77 day\(s\) left/);
});
test('a date under a month out is urgent, not merely due', () => {
const [state, detail] = plan(dated('2026-09-20'), TODAY);
assert.equal(state, 'urgent');
assert.match(detail, /not next cycle/);
});
test('a date beyond the window is left alone', () => {
assert.equal(plan(dated('2027-06-01'), TODAY)[0], 'later');
});
test('the window and the urgency line are both arguments', () => {
const model = dated('2026-11-15');
assert.equal(plan(model, TODAY)[0], 'due');
assert.equal(plan(model, TODAY, 30)[0], 'later');
assert.equal(plan(model, TODAY, 90, 120)[0], 'urgent');
});
test('a date already passed is out of scope for planning', () => {
const [state, detail] = plan(dated('2026-07-01'), TODAY);
assert.equal(state, 'expired');
assert.match(detail, /already failing/);
});
test('no date is unscheduled rather than safe', () => {
const [state, detail] = plan({ id: 'gpt-5.6-sol' }, TODAY);
assert.equal(state, 'unscheduled');
assert.match(detail, /Re-read/);
assert.equal(plan({ id: 'x', shutdown_date: 'Q4' }, TODAY)[0], 'unreadable-date');
});
test('unmeasured traffic and zero traffic do not read the same', () => {
assert.match(trafficNote(null), /no admin key/);
assert.match(trafficNote(0), /config file/);
assert.match(trafficNote(4000000), /4000000 request\(s\)/);
assert.match(plan(dated('2026-09-20'), TODAY, 90, 30, 0)[1], /config file/);
assert.match(plan(dated('2026-09-20'), TODAY)[1], /no admin key/);
});
FAQ
Why 90 days rather than 30 or 180?
Because it is roughly the notice period, so it is the longest window in which the field is reliably populated. It is still the wrong number for most teams, which is why it is an argument: pick the time a model change actually takes you to evaluate, canary and roll out, and set the window to that.
Why does the traffic join need a different key?
Usage and cost live on the organization, not on a project, so the /v1/organization/* endpoints reject project keys outright and want an organization admin key. The models list does not. The script keeps them separate and runs without the admin key, reporting the traffic column as unknown rather than pretending it is zero.
A flagged model has no traffic at all. Can I ignore it?
No, but the work is different. Zero requests in 30 days usually means the id survives in a config default, a fallback branch or a job that runs monthly. The first two should be deleted rather than migrated; the third will fail on its next run, after the date, with nobody watching.
Does Anthropic expose a retirement date I can check the same way?
No. The Claude models API returns created_at, display_name and the token limits, and no retirement date, so dates on that side come from the published deprecation table. The API contribution there is negative evidence: an id that has stopped appearing in GET /v1/models is already retired.
What stops the replacement from becoming the same problem in six months?
Nothing, which is the point of the last step. Pin the successor and then run this same check against the new id, so its date is on the calendar the day you adopt it rather than the day it expires.
Related field notes
- A model id past its published shutdown date
- A retired model id still sitting in the code
- A floating alias where a pinned snapshot belongs
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.
- Models — OpenAI API reference
- Deprecations — OpenAI API docs
- Model deprecations — Claude Docs
- Models API — Claude Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.