Diagnostic LLM APIs
The Videos API closes and no successor model is listed
The migration ticket is written before anybody reads the table properly, because everyone has done this before: find the retired id, look up the successor, change the string, ship. So the ticket says "migrate off sora-2" and it is assigned and estimated. Then somebody opens the deprecations page to fill in the target and finds the replacement column is empty. Not to be announced, not see the migration guide. Empty, for all five ids, because the thing being removed is not a model. It is the feature.
Confirm the closure, then stop looking for a target. GET /v1/models/sora-2 with a project read key returns a shutdown_date of 2026-09-24, and GET /v1/videos?limit=1 still returns a list today. That pair is the situation: the endpoint is alive, the date is close, and the deprecation table lists no replacement for sora-2, sora-2-pro, sora-2-2025-10-06, sora-2-2025-12-08 or sora-2-pro-2025-10-06. The script has a replacement table and it is empty on purpose, with a test that keeps it empty, because a script about a capability being withdrawn that helpfully suggests a successor is worse than no script.
The reading that is actually urgent is the second one, and it is not on the deprecations page at all. Every video object carries its own expires_at. So each rendered asset has two deadlines and you need the earlier of them: the day the endpoint closes, and the day that particular file stops being served. An asset whose expires_at falls before 2026-09-24 is already on the shorter clock, and an asset with no expiry still dies with the endpoint, because after the closure there is nothing left to fetch it from.
So the script walks GET /v1/videos to the end, converts each expires_at to a day, and reports the inventory sorted by deadline rather than by creation. What comes out is a download list with dates on it, which is the only artefact that matters between now and the shutdown.
Then size the surface with an admin-read key: GET /v1/organization/costs grouped by line_item, filtered to the video lines. Spend still accruing three weeks out means the product feature is live and customers are using it, which is a different conversation from an experiment nobody shipped.
The repair is removal, and the script prints it as removal: take out the /v1/videos code path and the sora-2* constants, and change whatever the product promises about video. That is the whole reason this note is separate from every other deprecation note in the section. The others end in a new string. This one ends in a decision.
The problem in plain words
Announced 24 March 2026, shutting down 24 September 2026: the Videos API and every Sora 2 model. Six months of notice, which is generous, and almost useless if the notice was read as a model retirement. The muscle memory for a deprecation notice is to find the successor id, and the ordinary case rewards that instantly. Here it fails silently: you look at the row, the replacement cell is blank, and a blank cell looks like a page that has not been filled in yet rather than a statement.
What is being removed is a product capability. There is no other OpenAI model that generates video, so nothing in the API can absorb the traffic. Anything built on it needs a third-party provider, a rebuild, or removal from the product, and all three of those are decisions somebody outside engineering has to make. That makes the lead time the important resource, and it is the resource a mis-read notice spends.
The quieter half is the assets. Generated videos are stored objects with an expires_at of their own, and the two clocks are independent. A file can expire while the API is still perfectly healthy, and every file that has not expired by 24 September becomes unreachable when the endpoint does, whatever its own expiry said. Teams that treat the shutdown as a code problem discover afterwards that the finished renders they were storing by id are now ids pointing at nothing.
And there is no warning shape for any of this. The endpoint returns 200 right up to the day. The models resolve. Costs keep posting. Nothing degrades, nothing warns, and then one morning a whole feature returns 404 and the only thing left to decide is what to tell customers.
Why it happens
An empty replacement column is a finding, not a gap in the documentation. This is the only closure in the batch where the correct output contains no migration target, so the script models that explicitly: a lookup function that returns None for every id, backed by a table that is empty and a test that asserts it stays empty. Left implicit, the next person to touch the script fills the table in with the closest-looking model, and the script starts lying in the most confident possible way.
Two clocks means you need the earlier one, per asset, not the earlier one overall. The endpoint date is uniform and the asset expiries are not, so the deadline is a property of each file. A summary that reports only "23 days left" is wrong for every asset whose expires_at lands sooner, and those are exactly the assets that go missing first. Sorting the inventory by deadline instead of by creation is what turns the report into a work queue.
An asset with no expiry is not safe, and the script refuses to leave it unlabelled. A null expires_at means this file has no clock of its own; it does not mean it has no clock. It inherits the endpoint's. That case is reported with the shutdown date attached rather than as an absence, because an absence is what somebody skims past.
Spend is the only readable measure of how much product is standing on this. Neither API lists requests, so "is this feature actually used" has to come from the cost report's video line items. It is a proxy and the script says so, but it separates the two situations that matter: a demo branch nobody deleted, and a live feature with customers on it and three weeks left.
This note reads endpoints and one named id list, not your configuration. There is a published note that diffs the model strings in your config against the model list, and it would find sora-2 there too — on the day it stops resolving, along with every other broken id, as one row among many. This one is early, specific and about a surface: it reads the five ids the deprecation table names, the endpoint that serves them, and the assets sitting behind it. The difference is that this one is useful in August rather than in October.
The repair is a decision and pretending otherwise wastes the notice. What the script prints is the shape of the removal plus the two things engineering cannot decide alone: whether the feature is replaced by a third-party provider or dropped, and what the customer-facing copy promising video generation now says. Printing a code diff would imply this is a code problem.
The fix, as a flow
Every other deprecation in this section ends in a string you paste into a config file. This one ends in a decision, because the replacement column is empty for all five ids and no other model absorbs the work. What is worth measuring instead is time, twice over: the endpoint has a date, and every rendered asset carries an expiry of its own. The deadline that matters is per file, and for some files it is the earlier one.
How to fix it
Read the shutdown date off the model objects, not off memory
GET /v1/models/sora-2 and the four other ids the deprecation table names. The shutdown_date field on the model object is the authority; the script reports which ids answered, which already 404, and which returned no date at all, in those three separate states.
Ask for the successor, and print that there is not one
The replacement lookup runs for every id and returns nothing for all of them. That is deliberate output, not a missing feature: this is the one closure in the section whose repair is not a substitution, and the script says so on the line where a model id would otherwise go.
Walk the whole video inventory to the end
GET /v1/videos?limit=100, paginating on after until the pages run out. Read id, status, created_at and above all expires_at. Do not stop at the first page: the oldest assets are the ones nearest their own expiry.
Take the earlier of the two clocks for each asset
Per asset, compare its expires_at to the endpoint's shutdown date. Earlier expiry wins and becomes that file's deadline; a null expiry inherits the shutdown; an already-past expiry means those bytes are gone and only the metadata row is left. Sort by deadline, not by creation.
Size the surface, then print the removal
GET /v1/organization/costs?bucket_width=1d&group_by[]=line_item over 30 days, filtered to the video lines. Then print the removal: the code path, the constants, and the customer-facing copy. No model id, because there is not one to print.
How to check it worked
Re-run after the download pass and the inventory should shrink from the top: assets you have fetched drop off the work queue, and the earliest deadlines are the ones that disappear first. What will not change is the empty replacement column, and it should not. The reading that tells you the removal has actually happened is the cost report going quiet, not the probe, because the endpoint answers until the day it does not.
OPENAI_API_KEY=sk-proj-... OPENAI_ADMIN_KEY=sk-admin-... \
python3 sora_shutdown_inventory.py --days 30
# endpoint /v1/videos closes 2026-09-24, 24 day(s) left
# sora-2 200 shutdown-dated shutdown_date 2026-09-24, 24 day(s) away
# sora-2-pro 200 shutdown-dated shutdown_date 2026-09-24, 24 day(s) away
# sora-2-2025-10-06 200 shutdown-dated shutdown_date 2026-09-24, 24 day(s) away
# no-replacement the deprecation table lists no successor for any of these
# ids, so there is no string to substitute
# 214 asset(s) in the inventory
# already-expired 12 the bytes are gone; only the metadata row is left
# expires-first 61 earliest 2026-09-02, which is 22 day(s) before the endpoint
# outlives-the-endpoint 118 their own expiry is later, so the endpoint closes first
# no-asset-expiry 23 no expiry of their own, so they die with the endpoint
# video-spend-accruing $412.80 on video line items in the last 30 day(s)
# repair: remove the /v1/videos code path and the sora-2 constants. This is a
# capability leaving the API, not a model changing name, so the decision is
# a third-party provider or dropping the feature
# 4 finding(s)
The full code
One GET per model id, a paginated walk of the video inventory, one cost report, and six pure functions. days_left, arithmetic against a published date; iso_day, which turns a unix stamp into a day and is separate so the two-clock logic can be tested without timestamps in it; replacement_for, which returns None for every id and exists precisely so a test can hold the replacement table empty; model_verdict, which grades one id and distinguishes a date read from the API from an id that no longer resolves; asset_deadline, the only function that compares the two clocks, and the only one that can say which of them a given file is on; and spend_verdict, which sizes the surface from the cost report and labels the number a proxy.
"""Inventory a capability that is being withdrawn, with no successor to move to.
Read only. Every request is a GET: the model objects for the five ids the
deprecation table names, the video listing, and the organization cost report.
Nothing here renders a video, and no request in this script creates anything.
Two things make this different from a model retirement. The deprecation table
lists no replacement for any Sora id, so the repair is a removal rather than a
substitution -- REPLACEMENTS below is empty on purpose and there is a test that
keeps it empty. And every rendered asset carries its own expires_at, so each
file has two deadlines and needs the earlier one.
"""
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("sora_shutdown_inventory")
API = "https://api.openai.com/v1"
# Announced 24 March 2026. The Videos API and every Sora 2 model close on this
# date. Published, and also readable: shutdown_date on the model object is the
# authority, and this constant is the fallback when the object carries no date.
SHUTDOWN = "2026-09-24"
# The five ids the deprecation table names.
SORA_IDS = ("sora-2", "sora-2-pro", "sora-2-2025-10-06", "sora-2-2025-12-08",
"sora-2-pro-2025-10-06")
# Empty on purpose, and kept empty by a test. Every Sora row in the deprecation
# table has an empty replacement column, because what is being withdrawn is a
# capability and not a model. The failure mode for a script about a capability
# removal is that a later reader fills this in with the closest-looking model
# id, at which point the script lies confidently.
REPLACEMENTS = {}
FINDINGS = ("shutdown-dated", "past-shutdown", "already-gone",
"already-expired", "expires-first", "outlives-the-endpoint",
"no-asset-expiry", "video-spend-accruing")
REPAIRS = {
"shutdown-dated":
"remove the /v1/videos code path and the sora-2 constants. This is a "
"capability leaving the API, not a model changing name, so the "
"decision is a third-party provider or dropping the feature.",
"past-shutdown":
"the date has passed. Anything still calling this path is returning "
"404 to somebody right now.",
"already-gone":
"this id no longer resolves, so the removal is already overdue for "
"whatever still names it.",
"already-expired":
"these bytes are gone and only the metadata row is left. If the render "
"mattered, it has to be regenerated before the endpoint closes, which "
"is the last chance there will be.",
"expires-first":
"download these before their own expiry, which lands sooner than the "
"endpoint shutdown. This is the front of the queue.",
"outlives-the-endpoint":
"download these before the shutdown. Their own expiry is later, which "
"is irrelevant once there is no endpoint left to serve them.",
"no-asset-expiry":
"no expiry of their own does not mean no deadline. They inherit the "
"endpoint's, so they need downloading like everything else.",
"video-spend-accruing":
"this is a live feature with money moving through it, not a branch "
"somebody forgot. Whoever owns the customer-facing promise of video "
"generation needs the date before engineering picks a plan.",
}
def days_left(today, when=SHUTDOWN):
"""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 iso_day(stamp):
"""A unix second stamp as a UTC day, or None. Pure.
Kept separate from asset_deadline() so the two-clock comparison can be
tested in dates rather than in timestamps, which is the part of it that is
easy to get wrong.
"""
if stamp in (None, "", 0):
return None
try:
return dt.datetime.fromtimestamp(int(stamp),
dt.timezone.utc).date().isoformat()
except (TypeError, ValueError, OSError, OverflowError):
return None
def replacement_for(model_id):
"""The documented successor for one id. Pure. Returns None, every time.
The lookup exists so that the absence is printed rather than assumed. See
REPLACEMENTS above for why it is empty and why it stays that way.
"""
return REPLACEMENTS.get(str(model_id))
def model_verdict(model_id, status, shutdown_date, today):
"""Grade one model id. Pure. Returns (state, detail).
Distinguishes a date the API stated from a date only the published table
knows, because those are different levels of evidence and the second one
goes stale without telling anybody.
"""
if status is None:
return ("unreachable", "no response for %s" % model_id)
status = int(status)
if status == 404:
return ("already-gone",
"%s no longer resolves, so it is out of the model list already"
% model_id)
if status != 200:
return ("unreadable",
"%d for %s, so nothing can be read about it" % (status, model_id))
if not shutdown_date:
return ("no-date-from-api",
"the model object carried no shutdown_date, so the published "
"table is the only source and it says %s" % SHUTDOWN)
left = days_left(today, shutdown_date)
if left < 0:
return ("past-shutdown",
"shutdown_date %s, which was %d day(s) ago"
% (shutdown_date, -left))
return ("shutdown-dated",
"shutdown_date %s, %d day(s) away" % (shutdown_date, left))
def asset_deadline(expires_iso, today, when=SHUTDOWN):
"""The earlier of an asset's two clocks. Pure. (state, deadline, detail).
The only function here that compares them, and the reason the report is
sorted by deadline rather than by creation date. A null expiry is not an
absence of a deadline: it inherits the endpoint's.
"""
today = str(today)
when = str(when)
if not expires_iso:
return ("no-asset-expiry", when,
"no expiry of its own, so it dies with the endpoint on %s" % when)
expires_iso = str(expires_iso)
if expires_iso <= today:
return ("already-expired", expires_iso,
"expired on %s, so the bytes are already unreachable"
% expires_iso)
if expires_iso < when:
gap = days_left(expires_iso, when)
return ("expires-first", expires_iso,
"expires %s, which is %d day(s) before the endpoint closes"
% (expires_iso, gap))
return ("outlives-the-endpoint", when,
"its own expiry is %s, so the endpoint closes first on %s"
% (expires_iso, when))
def spend_verdict(rows, days):
"""Sum the video line items. Pure. Returns (state, total, detail).
A proxy and labelled as one: neither API lists requests, so spend is the
only readable measure of how much product is standing on this surface.
"""
total = 0.0
for name, amount in rows or []:
text = str(name or "").lower()
if "video" in text or "sora" in text:
total += float(amount or 0)
if total > 0:
return ("video-spend-accruing", total,
"$%.2f on video line items in the last %d day(s), which is a "
"live feature rather than a branch somebody forgot"
% (total, days))
return ("no-video-spend", 0.0,
"no video line items in the last %d day(s). That is a proxy: it "
"means nothing was billed, not that nothing calls the endpoint"
% days)
def repair_lines(state):
"""The repair for one verdict. Pure. Printed, never performed."""
line = REPAIRS.get(state)
if not line:
return []
if state in ("shutdown-dated", "past-shutdown", "already-gone"):
return [line,
"there is no successor model id to print here. The replacement "
"column is empty for every Sora id in the deprecation table."]
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_videos(session, key, pages=50):
"""Walk GET /v1/videos to the end. The oldest assets expire first."""
out, after = [], None
for _ in range(pages):
params = {"limit": 100, "order": "asc"}
if after:
params["after"] = after
status, body = get_json(session, "/videos", key, params)
if status != 200:
log.warning("video listing came back %s, so the inventory is "
"incomplete", 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 cost_rows(session, key, days):
"""[(line_item, amount)] from the daily cost report."""
start = int((dt.datetime.now(dt.timezone.utc)
- dt.timedelta(days=days)).timestamp())
status, body = get_json(session, "/organization/costs", key,
{"start_time": start, "bucket_width": "1d",
"group_by[]": ["line_item"], "limit": 180})
if status != 200:
log.warning("cost report came back %s, so the surface was not sized",
status)
return []
rows = []
for bucket in body.get("data") or []:
for row in bucket.get("results") or []:
amount = row.get("amount") or {}
rows.append((row.get("line_item"), amount.get("value")))
return rows
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--days", type=int, default=30,
help="days of cost buckets to read")
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")
return 2
session = requests.Session()
findings = 0
log.info("endpoint /v1/videos closes %s, %d day(s) left", SHUTDOWN,
days_left(args.today))
for model_id in SORA_IDS:
status, body = get_json(session, "/models/" + model_id, key)
state, detail = model_verdict(model_id, status,
(body or {}).get("shutdown_date"),
args.today)
emit = log.warning if state in FINDINGS else log.info
emit(" %-26s %s %-15s %s", model_id,
"---" if status is None else status, state, detail)
if replacement_for(model_id):
log.error(" the replacement table is not empty. Read the note "
"before trusting this line")
log.warning(" %-26s the deprecation table lists no successor for any of "
"these ids, so there is no string to substitute",
"no-replacement")
for line in repair_lines("shutdown-dated"):
log.warning(" repair: %s", line)
findings += 1
videos = all_videos(session, key)
log.info("%d asset(s) in the inventory", len(videos))
buckets = {}
for video in videos:
state, deadline, detail = asset_deadline(
iso_day(video.get("expires_at")), args.today)
entry = buckets.setdefault(state, [0, deadline, detail])
entry[0] += 1
if deadline < entry[1]:
entry[1], entry[2] = deadline, detail
for state, (count, deadline, detail) in sorted(
buckets.items(), key=lambda kv: kv[1][1]):
emit = log.warning if state in FINDINGS else log.info
emit(" %-22s %4d earliest %s: %s", state, count, deadline, detail)
for line in repair_lines(state):
emit(" repair: %s", line)
if state in FINDINGS:
findings += 1
admin = os.environ.get("OPENAI_ADMIN_KEY")
if not admin:
log.info("%-22s no admin key, so the surface was not sized",
"not-sized")
else:
state, total, detail = spend_verdict(cost_rows(session, admin, args.days),
args.days)
emit = log.warning if state in FINDINGS else log.info
emit("%-22s %s", state, detail)
for line in repair_lines(state):
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())
/**
* Inventory a capability being withdrawn, with no successor to move to.
*
* Read only. Every request is a GET: the model objects for the five ids the
* deprecation table names, the video listing, and the cost report. Nothing
* here renders a video and no request in this script creates anything.
*
* The deprecation table lists no replacement for any Sora id, so REPLACEMENTS
* below is empty on purpose and a test keeps it empty. And every asset carries
* its own expires_at, so each file has two deadlines and needs the earlier one.
*/
export const API = 'https://api.openai.com/v1';
// Announced 24 March 2026. Published, and also readable as shutdown_date.
export const SHUTDOWN = '2026-09-24';
export const SORA_IDS = ['sora-2', 'sora-2-pro', 'sora-2-2025-10-06',
'sora-2-2025-12-08', 'sora-2-pro-2025-10-06'];
// Empty on purpose, and kept empty by a test. What is being withdrawn is a
// capability and not a model, so filling this in with the closest-looking id
// would make the script lie confidently.
export const REPLACEMENTS = {};
const FINDINGS = new Set(['shutdown-dated', 'past-shutdown', 'already-gone',
'already-expired', 'expires-first', 'outlives-the-endpoint',
'no-asset-expiry', 'video-spend-accruing']);
const REPAIRS = {
'shutdown-dated':
'remove the /v1/videos code path and the sora-2 constants. This is a '
+ 'capability leaving the API, not a model changing name, so the decision '
+ 'is a third-party provider or dropping the feature.',
'past-shutdown':
'the date has passed. Anything still calling this path is returning 404 to '
+ 'somebody right now.',
'already-gone':
'this id no longer resolves, so the removal is already overdue for '
+ 'whatever still names it.',
'already-expired':
'these bytes are gone and only the metadata row is left. If the render '
+ 'mattered, it has to be regenerated before the endpoint closes.',
'expires-first':
'download these before their own expiry, which lands sooner than the '
+ 'endpoint shutdown. This is the front of the queue.',
'outlives-the-endpoint':
'download these before the shutdown. Their own expiry is later, which is '
+ 'irrelevant once there is no endpoint left to serve them.',
'no-asset-expiry':
'no expiry of their own does not mean no deadline. They inherit the '
+ "endpoint's, so they need downloading like everything else.",
'video-spend-accruing':
'this is a live feature with money moving through it, not a branch '
+ 'somebody forgot. Whoever owns the customer-facing promise of video '
+ 'generation needs the date before engineering picks a plan.',
};
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 = SHUTDOWN) {
return Math.round((day(String(when)) - day(String(today))) / 86400000);
}
/** A unix second stamp as a UTC day, or null. Pure. */
export function isoDay(stamp) {
if (stamp === null || stamp === undefined || stamp === '' || stamp === 0) return null;
const n = Number(stamp);
if (!Number.isFinite(n)) return null;
const d = new Date(n * 1000);
if (Number.isNaN(d.getTime())) return null;
return d.toISOString().slice(0, 10);
}
/** The documented successor for one id. Pure. Returns undefined, every time. */
export function replacementFor(modelId) {
return REPLACEMENTS[String(modelId)];
}
/** Grade one model id. Pure. [state, detail]. */
export function modelVerdict(modelId, status, shutdownDate, today) {
if (status === null || status === undefined) {
return ['unreachable', `no response for ${modelId}`];
}
const s = Number(status);
if (s === 404) {
return ['already-gone',
`${modelId} no longer resolves, so it is out of the model list already`];
}
if (s !== 200) {
return ['unreadable', `${s} for ${modelId}, so nothing can be read about it`];
}
if (!shutdownDate) {
return ['no-date-from-api',
'the model object carried no shutdown_date, so the published table is '
+ `the only source and it says ${SHUTDOWN}`];
}
const left = daysLeft(today, shutdownDate);
if (left < 0) {
return ['past-shutdown', `shutdown_date ${shutdownDate}, which was ${-left} day(s) ago`];
}
return ['shutdown-dated', `shutdown_date ${shutdownDate}, ${left} day(s) away`];
}
/** The earlier of an asset's two clocks. Pure. [state, deadline, detail]. */
export function assetDeadline(expiresIso, today, when = SHUTDOWN) {
const t = String(today);
const w = String(when);
if (!expiresIso) {
return ['no-asset-expiry', w,
`no expiry of its own, so it dies with the endpoint on ${w}`];
}
const e = String(expiresIso);
if (e <= t) {
return ['already-expired', e, `expired on ${e}, so the bytes are already unreachable`];
}
if (e < w) {
return ['expires-first', e,
`expires ${e}, which is ${daysLeft(e, w)} day(s) before the endpoint closes`];
}
return ['outlives-the-endpoint', w,
`its own expiry is ${e}, so the endpoint closes first on ${w}`];
}
/** Sum the video line items. Pure. [state, total, detail]. */
export function spendVerdict(rows, days) {
let total = 0;
for (const [name, amount] of rows || []) {
const text = String(name ?? '').toLowerCase();
if (text.includes('video') || text.includes('sora')) total += Number(amount) || 0;
}
if (total > 0) {
return ['video-spend-accruing', total,
`$${total.toFixed(2)} on video line items in the last ${days} day(s), which `
+ 'is a live feature rather than a branch somebody forgot'];
}
return ['no-video-spend', 0,
`no video line items in the last ${days} day(s). That is a proxy: it means `
+ 'nothing was billed, not that nothing calls the endpoint'];
}
/** The repair for one verdict. Pure. Printed, never performed. */
export function repairLines(state) {
const line = REPAIRS[state];
if (!line) return [];
if (state === 'shutdown-dated' || state === 'past-shutdown' || state === 'already-gone') {
return [line,
'there is no successor model id to print here. The replacement column is '
+ 'empty for every Sora id in the deprecation table.'];
}
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 allVideos(key, pages = 50) {
const out = [];
let after = null;
for (let i = 0; i < pages; i += 1) {
const params = { limit: 100, order: 'asc' };
if (after) params.after = after;
const [status, body] = await getJson('/videos', key, params);
if (status !== 200) {
console.log(`video listing came back ${status}, so the inventory is incomplete`);
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 costRows(key, days) {
const start = Math.floor(Date.now() / 1000) - days * 86400;
const [status, body] = await getJson('/organization/costs', key, {
start_time: start, bucket_width: '1d', 'group_by[]': ['line_item'], limit: 180,
});
if (status !== 200) {
console.log(`cost report came back ${status}, so the surface was not sized`);
return [];
}
const rows = [];
for (const bucket of body.data || []) {
for (const row of bucket.results || []) {
rows.push([row.line_item, (row.amount || {}).value]);
}
}
return rows;
}
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');
process.exitCode = 2;
return;
}
const today = process.env.TODAY || new Date().toISOString().slice(0, 10);
const days = Number(process.env.DAYS || 30);
let findings = 0;
console.log(`endpoint /v1/videos closes ${SHUTDOWN}, ${daysLeft(today)} day(s) left`);
for (const modelId of SORA_IDS) {
const [status, body] = await getJson(`/models/${modelId}`, key);
const [state, detail] = modelVerdict(modelId, status, body.shutdown_date, today);
console.log(` ${modelId.padEnd(26)} ${status ?? '---'} ${state.padEnd(15)} ${detail}`);
if (replacementFor(modelId)) {
console.error(' the replacement table is not empty. Read the note before '
+ 'trusting this line');
}
}
console.log(` ${'no-replacement'.padEnd(26)} the deprecation table lists no successor `
+ 'for any of these ids, so there is no string to substitute');
for (const line of repairLines('shutdown-dated')) console.log(` repair: ${line}`);
findings += 1;
const videos = await allVideos(key);
console.log(`${videos.length} asset(s) in the inventory`);
const buckets = new Map();
for (const video of videos) {
const [state, deadline, detail] = assetDeadline(isoDay(video.expires_at), today);
const entry = buckets.get(state) || [0, deadline, detail];
entry[0] += 1;
if (deadline < entry[1]) { entry[1] = deadline; entry[2] = detail; }
buckets.set(state, entry);
}
for (const [state, [count, deadline, detail]] of
[...buckets.entries()].sort((a, b) => (a[1][1] < b[1][1] ? -1 : 1))) {
console.log(` ${state.padEnd(22)} ${String(count).padStart(4)} earliest ${deadline}: ${detail}`);
for (const line of repairLines(state)) console.log(` repair: ${line}`);
if (FINDINGS.has(state)) findings += 1;
}
const admin = process.env.OPENAI_ADMIN_KEY;
if (!admin) {
console.log(`${'not-sized'.padEnd(22)} no admin key, so the surface was not sized`);
} else {
const [state, , detail] = spendVerdict(await costRows(admin, days), days);
console.log(`${state.padEnd(22)} ${detail}`);
for (const line of repairLines(state)) console.log(` repair: ${line}`);
if (FINDINGS.has(state)) 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 one that keeps the note honest: the replacement table is empty, replacement_for returns nothing for all five ids, and the repair text says in words that there is no successor to print. If a later reader fills that table in, this test fails and tells them why. The rest is the two clocks. An asset whose expires_at lands before the shutdown is on the earlier one and the detail says by how many days; one whose expiry is later is on the endpoint's, because the endpoint closes first; a null expiry inherits the shutdown rather than counting as no deadline at all; and an expiry already in the past is bytes that are gone. Then the model grading, which separates a shutdown_date the API stated from a 404 and from a model object that carried no date. And finally the spend proxy, asserted to describe itself as a proxy in the zero case, since that is the case somebody will read as an all-clear.
from sora_shutdown_inventory import (REPLACEMENTS, SHUTDOWN, SORA_IDS,
asset_deadline, days_left, iso_day,
model_verdict, repair_lines,
replacement_for, spend_verdict)
TODAY = "2026-08-31"
def test_there_is_no_successor_and_the_script_refuses_to_invent_one():
# If somebody fills the table in with the closest-looking model id, this
# fails and the message above it explains why that is not a kindness.
assert REPLACEMENTS == {}
for model_id in SORA_IDS:
assert replacement_for(model_id) is None
joined = " ".join(repair_lines("shutdown-dated"))
assert "no successor model id" in joined
assert "capability leaving the API" in joined
assert "third-party provider or dropping the feature" in joined
def test_an_asset_that_expires_first_is_on_the_earlier_clock():
state, deadline, detail = asset_deadline("2026-09-02", TODAY)
assert state == "expires-first"
assert deadline == "2026-09-02"
assert "22 day(s) before the endpoint closes" in detail
assert any("front of the queue" in line for line in repair_lines(state))
def test_an_asset_that_outlives_its_expiry_still_dies_with_the_endpoint():
state, deadline, detail = asset_deadline("2026-12-01", TODAY)
assert state == "outlives-the-endpoint"
assert deadline == SHUTDOWN
assert "the endpoint closes first" in detail
# A null expiry is not an absence of a deadline. It inherits one.
state, deadline, detail = asset_deadline(None, TODAY)
assert state == "no-asset-expiry"
assert deadline == SHUTDOWN
assert "dies with the endpoint" in detail
assert any("inherit" in line for line in repair_lines(state))
def test_an_expiry_already_past_means_the_bytes_are_gone():
state, deadline, detail = asset_deadline("2026-08-04", TODAY)
assert state == "already-expired"
assert deadline == "2026-08-04"
assert "already unreachable" in detail
assert asset_deadline(TODAY, TODAY)[0] == "already-expired"
def test_unix_stamps_become_days_and_bad_ones_become_nothing():
assert iso_day(1788000000) == "2026-08-29"
assert iso_day(None) is None
assert iso_day(0) is None
assert iso_day("not a stamp") is None
assert days_left(TODAY) == 24
assert days_left("2026-10-01") == -7
def test_a_stated_shutdown_date_is_graded_apart_from_a_missing_one():
state, detail = model_verdict("sora-2", 200, SHUTDOWN, TODAY)
assert state == "shutdown-dated"
assert "24 day(s) away" in detail
state, detail = model_verdict("sora-2", 200, None, TODAY)
assert state == "no-date-from-api"
assert "published table is the only source" in detail
assert model_verdict("sora-2", 404, None, TODAY)[0] == "already-gone"
assert model_verdict("sora-2", 401, None, TODAY)[0] == "unreadable"
assert model_verdict("sora-2", None, None, TODAY)[0] == "unreachable"
assert model_verdict("sora-2", 200, "2026-08-01", TODAY)[0] == "past-shutdown"
def test_spend_is_a_proxy_and_says_so_in_the_case_that_looks_like_an_all_clear():
state, total, detail = spend_verdict(
[("Video generation", 400.5), ("sora-2-pro", 12.3), ("Text tokens", 99)], 30)
assert state == "video-spend-accruing"
assert round(total, 2) == 412.80
assert "412.80" in detail
state, total, detail = spend_verdict([("Text tokens", 99)], 30)
assert state == "no-video-spend"
assert total == 0.0
assert "That is a proxy" in detail
assert repair_lines(state) == []
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { REPLACEMENTS, SHUTDOWN, SORA_IDS, assetDeadline, daysLeft, isoDay,
modelVerdict, repairLines, replacementFor,
spendVerdict } from './sora-shutdown-inventory.mjs';
const TODAY = '2026-08-31';
test('there is no successor and the script refuses to invent one', () => {
assert.deepEqual(REPLACEMENTS, {});
for (const id of SORA_IDS) assert.equal(replacementFor(id), undefined);
const joined = repairLines('shutdown-dated').join(' ');
assert.ok(joined.includes('no successor model id'));
assert.ok(joined.includes('capability leaving the API'));
assert.ok(joined.includes('third-party provider or dropping the feature'));
});
test('an asset that expires first is on the earlier clock', () => {
const [state, deadline, detail] = assetDeadline('2026-09-02', TODAY);
assert.equal(state, 'expires-first');
assert.equal(deadline, '2026-09-02');
assert.ok(detail.includes('22 day(s) before the endpoint closes'));
assert.ok(repairLines(state).some((l) => l.includes('front of the queue')));
});
test('an asset that outlives its expiry still dies with the endpoint', () => {
let [state, deadline, detail] = assetDeadline('2026-12-01', TODAY);
assert.equal(state, 'outlives-the-endpoint');
assert.equal(deadline, SHUTDOWN);
assert.ok(detail.includes('the endpoint closes first'));
[state, deadline, detail] = assetDeadline(null, TODAY);
assert.equal(state, 'no-asset-expiry');
assert.equal(deadline, SHUTDOWN);
assert.ok(detail.includes('dies with the endpoint'));
assert.ok(repairLines(state).some((l) => l.includes('inherit')));
});
test('an expiry already past means the bytes are gone', () => {
const [state, deadline, detail] = assetDeadline('2026-08-04', TODAY);
assert.equal(state, 'already-expired');
assert.equal(deadline, '2026-08-04');
assert.ok(detail.includes('already unreachable'));
assert.equal(assetDeadline(TODAY, TODAY)[0], 'already-expired');
});
test('unix stamps become days and bad ones become nothing', () => {
assert.equal(isoDay(1788000000), '2026-08-29');
assert.equal(isoDay(null), null);
assert.equal(isoDay(0), null);
assert.equal(isoDay('not a stamp'), null);
assert.equal(daysLeft(TODAY), 24);
assert.equal(daysLeft('2026-10-01'), -7);
});
test('a stated shutdown date is graded apart from a missing one', () => {
let [state, detail] = modelVerdict('sora-2', 200, SHUTDOWN, TODAY);
assert.equal(state, 'shutdown-dated');
assert.ok(detail.includes('24 day(s) away'));
[state, detail] = modelVerdict('sora-2', 200, null, TODAY);
assert.equal(state, 'no-date-from-api');
assert.ok(detail.includes('published table is the only source'));
assert.equal(modelVerdict('sora-2', 404, null, TODAY)[0], 'already-gone');
assert.equal(modelVerdict('sora-2', 401, null, TODAY)[0], 'unreadable');
assert.equal(modelVerdict('sora-2', null, null, TODAY)[0], 'unreachable');
assert.equal(modelVerdict('sora-2', 200, '2026-08-01', TODAY)[0], 'past-shutdown');
});
test('spend is a proxy and says so in the case that looks like an all clear', () => {
let [state, total, detail] = spendVerdict(
[['Video generation', 400.5], ['sora-2-pro', 12.3], ['Text tokens', 99]], 30);
assert.equal(state, 'video-spend-accruing');
assert.equal(Math.round(total * 100) / 100, 412.8);
assert.ok(detail.includes('412.80'));
[state, total, detail] = spendVerdict([['Text tokens', 99]], 30);
assert.equal(state, 'no-video-spend');
assert.equal(total, 0);
assert.ok(detail.includes('That is a proxy'));
assert.deepEqual(repairLines(state), []);
});
FAQ
What do I migrate sora-2 to?
Nothing, and that is the finding rather than a gap in the answer. The deprecation table's replacement column is empty for sora-2, sora-2-pro and all three dated snapshots, because what is being withdrawn is video generation as a capability rather than one model within it. No other OpenAI model absorbs the traffic. The script's replacement table is empty on purpose and a test keeps it that way, so the output says there is no successor instead of guessing at the closest-looking id.
Why does the script care about expires_at when the endpoint is closing anyway?
Because the two clocks are independent and the asset one is often earlier. Every video object carries its own expires_at, so a file can become unreachable weeks before 24 September while the API is still perfectly healthy. Sorting the inventory by the earlier of the two deadlines turns it into a download queue with real dates on it, which is the only artefact worth producing between now and the shutdown. An asset with no expiry of its own is not exempt; it simply inherits the endpoint's date.
Is this not the same as the note about a model past its shutdown date?
No, and the difference is timing as much as mechanism. That note diffs the model strings in your configuration against the model list, which will find sora-2 on the day it stops resolving, as one broken id among however many others. This one reads the five ids the table names, the endpoint that serves them, and the assets sitting behind it, which is useful in August rather than in October. It also owns the part the model list cannot express: the endpoint itself is going, not just the ids.
Can I not just keep the rendered videos I already have?
Only if you fetch them first. The stored assets live behind the same API that is closing, so after the shutdown an id you hold is an id pointing at nothing, whatever that asset's own expires_at said. The realistic plan is to walk the whole inventory now, download what the product actually needs, and store it somewhere you own. The script produces the list, sorted by deadline; it deliberately does not download anything itself, because that is not a read-only operation in any useful sense.
How do I tell whether this feature is actually being used?
From the cost report, and only approximately. Neither API lists individual requests, so the readable signal is video line items on GET /v1/organization/costs grouped by line_item. Money still moving through the surface three weeks out means live customers rather than a branch nobody deleted. The script labels that number a proxy, including in the zero case, because no video spend means nothing was billed in the window and not that nothing calls the endpoint.
Related field notes
- The same closure one date earlier, and already past it
- What the cost report calls the media you generate
- Another stored object that deletes itself on a schedule
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
- List videos — OpenAI API reference
- Retrieve model, including the shutdown_date field
- Costs — 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.