Diagnostic LLM APIs
purpose=assistants files outlived the API that owned them
The Assistants API reached its shutdown date on 26 August 2026 and the migration was done months ago. Runs became responses, threads became conversations, the beta header came out of the client, and the whole thing has been working ever since. What nobody did, because nothing asked and nothing broke, was go and look at the files. They are still there. Every document ever uploaded for an assistant, every code-interpreter output from a run that no longer exists, sitting in a store whose owner was deleted, counting against a ceiling and answering every listing call as though the last two years never happened.
This is a subtraction, not a measurement. One set is every file carrying a purpose whose owning API is gone: GET /v1/files?purpose=assistants and GET /v1/files?purpose=assistants_output, both paged on after with a project read key. Both values are still enumerated on the File object, so those listings still work and still return things.
The other set is every file id a live vector store still holds. Walk GET /v1/vector_stores (limit caps at 100 there, not the 10,000 the files endpoint allows) and then GET /v1/vector_stores/{vector_store_id}/files for each one. The vector-store file object's id is the underlying Files-API id, which is what makes the subtraction possible at all.
What is left over is owned by nothing. It is not attached to a vector store, and the assistant, thread and run that used to point at it no longer exist as objects you can query. Those files are still enumerable, still counted against the project's storage ceiling, and referenced by nothing.
Be careful about what is documented here, because the script is. OpenAI's migration guide covers assistants, threads and runs and never mentions files, vector stores or purposes at all. So "files carry over" is an inference. What the platform does state is narrower and sufficient: those two purpose values are still valid on the File object, vector stores are a live non-beta resource with their own reference page, and DELETE /v1/files/{file_id} is documented to remove the file from every vector store it is in. The output labels the measured half and the inferred half separately.
The whole verdict rests on the set you subtract, so a store whose file listing failed poisons the arithmetic. When that happens the script downgrades every orphan in the run rather than skipping one store, because a set difference against an incomplete set names files that are perfectly well referenced.
The problem in plain words
An ownership graph is not storage, and only one of them was decommissioned. Assistants, threads, messages and runs were objects on OpenAI's side; the files they referenced were separate objects with their own lifecycle. When the endpoint family reached its shutdown date, the graph went. The files did not, because nothing about deleting an API deletes the blobs it happened to point at, and nobody would want it to.
What makes this a note rather than an obvious consequence is that the migration is written entirely in terms of code. The guide is about turning a run into a response and a thread into a conversation; it does not have a storage chapter, because the storage kept working perfectly throughout. Nothing in the migration path takes you past your own files, so the last time anybody looked at them was before the year of notice started.
And the leftover class is unusually large in a way that is easy to underestimate. purpose=assistants was the upload path for every document anyone ever attached to an assistant, including the ones attached during the prototype, the demo and the abandoned second product. purpose=assistants_output is worse: it is every artefact the code interpreter ever wrote, one per run, generated automatically by traffic rather than deliberately by a person. Nobody uploads those and nobody has a list of them.
The consequence is entirely quiet. They do not fail. They do not error. They cost storage against a project ceiling nothing reports, and they sit in an account as a body of documents nobody has an inventory of and nobody has reviewed, which is a different problem from a cost problem and lands on a different desk.
Why it happens
A subtraction is only as good as the set you subtract, so an unreadable store voids the run. This is the single most important design decision in the script. If one vector store's file listing 403s or times out, the ids inside it are missing from the referenced set, and every one of those files then looks like an orphan. Printing them anyway would be worse than printing nothing, because the output of this script is a list of deletion commands. So a partial referenced set downgrades every verdict in the run to subtraction-incomplete, names the stores it could not read, and produces no deletion lines at all.
The two purposes are one class and two different problems. assistants files were uploaded by a person who chose to upload them, so some of them are documents somebody still wants and the repair is an archive followed by a deletion. assistants_output files were generated by the platform, one per code-interpreter run, and were only ever meaningful inside a run object that no longer exists. The script grades them as separate states for that reason, and the second one is a much easier conversation.
The vector store's file id is the file id, and that is what makes this cheap. There is no join table and no lookup: the object returned by GET /v1/vector_stores/{id}/files carries the underlying Files-API id directly, so the referenced set is built by reading one field. It also means a single set membership test answers the whole question for a file, rather than a per-file query against every store.
The pagination limits are not the same on the two endpoints and copying one to the other silently truncates. The files listing accepts a limit of up to 10,000 and defaults to it. The vector store listing and the vector-store-files listing both cap at 100 and default to 20. A script that reuses the first number on the second endpoint gets a page of 100 and a referenced set that is missing everything after it, which turns into a confident list of orphans that are not orphans.
Deleting a file detaches it from every vector store, which is why the order matters. The delete call is documented as removing the file from all vector stores as well. So a mistaken deletion is not just an unrecoverable file, it is a hole in a live retrieval index that will show up later as an answer with fewer citations and no error. That is the failure mode of the note next door about a store that silently lost a document, and it is why nothing here executes anything.
This is not the shutdown probe, and the two answer different questions. The published note on the Assistants shutdown asks whether /v1/assistants still answers for your organization and grades a status code against a control path. That is a question about access. This is a question about inventory, it assumes the endpoint is gone, and it would produce exactly the same findings on an organization that migrated cleanly two years ago.
The fix, as a flow
This is a subtraction rather than a measurement. One set is every file carrying a purpose whose owning API no longer exists; the other is every file id still reachable from a vector store, because stores and their files survived the shutdown that removed the assistants and threads pointing at them. What is left over is owned by nothing. The risk in the arithmetic is the second set: read it partially and the difference names files that are perfectly well referenced.
How to fix it
Use the project key that owns the vector stores
Vector stores are project-scoped, so a key from the wrong project returns a clean, empty store list and every file in the class then looks unreferenced. The script reports how many stores it found, and a run that found zero says so loudly rather than reporting a project full of orphans.
List both purposes, paged to the end
GET /v1/files?purpose=assistants and GET /v1/files?purpose=assistants_output. Both values are still enumerated on the File object even though the API that produced them is gone, so these listings return real results. Page on after until has_more is false.
Build the referenced set from the stores, at their own page size
GET /v1/vector_stores?limit=100, then GET /v1/vector_stores/{vector_store_id}/files?limit=100 for each. Both cap at 100 rather than the files endpoint's 10,000. Each vector-store file object's id is the Files-API id, so the set is one field per row.
Refuse to subtract from an incomplete set
If any store's listing failed, the run is graded subtraction-incomplete in full. The script names the stores it could not read and prints no deletion commands, because the alternative is a deletion list containing files that are in a store the script could not see.
Archive first, then delete, one command at a time
The printed repair is DELETE /v1/files/{file_id} per confirmed orphan, and it is printed rather than run. Deleted files cannot be recovered, and the delete also removes the file from every vector store holding it, so a wrong id costs you a document and a hole in an index.
How to check it worked
Re-run after a sweep and the class should shrink to the files you decided to keep, all of which should come back as still-referenced or as a purpose class you have consciously chosen not to empty. The reading that matters more on the second run is the store count: it should be the same. A run whose store count dropped is not evidence that you cleaned up, it is evidence that the key or the project changed, and the script prints that number first for exactly that reason.
python3 openai_orphaned_assistant_files.py
# 6 vector store(s) read, 1,204 referenced file id(s)
# 318 file(s) in the class: 241 assistants, 77 assistants_output, 12.4 GiB
# measured: two purpose listings, minus the ids held by every store read
# inferred: that a file in no surviving store has no owner. The migration
# guide documents nothing at all about files or vector stores
# class-populated 318 file(s) carry a purpose whose owning API no longer
# exists
# orphan file-3ab: no surviving vector store holds this id,
# 41.2 MiB, uploaded 511 day(s) ago
# orphan file-77c: no surviving vector store holds this id,
# 2.9 MiB, uploaded 447 day(s) ago
# orphan-output file-b19: code interpreter output from a run that no
# longer exists, 118.0 KiB, created 502 day(s) ago
# still-referenced file-c04: held by a live vector store, so file search
# under the Responses API still reads it
# repair: 289 confirmed orphan(s), 11.8 GiB. Archive anything you still want,
# then DELETE /v1/files/{file_id} one at a time. The delete also
# removes the file from every vector store holding it.
# repair: re-upload future file search sources with purpose user_data and an
# expires_after policy, so the next class ages out on its own.
# 289 finding(s)
The full code
Four kinds of paged GET and seven pure functions. file_row and referenced_ids, the two sides of the subtraction, the second of which reads the vector-store file object's own id because that id is the Files-API id; human and age_days, the formatting the output needs; class_state, which grades the purpose class as a whole and has an outcome for the class being empty; classify_file, which puts the completeness of the referenced set ahead of every other test so that an unreadable store cannot produce a deletion command; summarise, which folds the graded rows into the counts the repair line quotes; and repair_lines, which names the vector-store side effect of a delete in the same breath as the delete.
"""Subtract the ids surviving vector stores hold from one dead purpose class.
Read only. Four kinds of GET: two file listings, the vector store listing, and
one file listing per store. Nothing is created and nothing is deleted.
The Assistants API reached its shutdown date on 2026-08-26. Its objects went;
the files they referenced did not, because deleting an API does not delete
storage. `assistants` and `assistants_output` are still valid values of
`purpose` on the File object, so those files still enumerate and still count
against the project's storage ceiling.
One honesty note carried into the output. OpenAI's migration guide covers
assistants, threads and runs and says nothing whatsoever about files, vector
stores or purposes, so "a file in no surviving store has no owner" is an
inference from what is readable rather than a documented fact. What is
documented is that the two purposes remain valid, that vector stores are a
live resource, and that deleting a file removes it from every vector store.
The subtraction is only as good as the set being subtracted, so a store whose
file listing could not be read downgrades every verdict in the run.
"""
import argparse
import logging
import os
import sys
import time
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("openai_orphaned_assistant_files")
FILES_URL = "https://api.openai.com/v1/files"
STORES_URL = "https://api.openai.com/v1/vector_stores"
PURPOSES = ("assistants", "assistants_output")
FINDINGS = ("orphan", "orphan-output", "subtraction-incomplete")
# The files listing accepts up to 10,000 per page; both vector store listings
# cap at 100. Copying the first number onto the second endpoint is how a
# referenced set silently loses everything after row 100.
FILE_PAGE = 10000
STORE_PAGE = 100
def file_row(body):
"""One file object, reduced. Pure."""
body = body if isinstance(body, dict) else {}
try:
size = int(body.get("bytes"))
except (TypeError, ValueError):
size = 0
try:
created = int(body.get("created_at") or 0)
except (TypeError, ValueError):
created = 0
return {"id": str(body.get("id") or ""),
"filename": str(body.get("filename") or ""),
"size": max(0, size),
"purpose": str(body.get("purpose") or ""),
"created_at": max(0, created)}
def referenced_ids(store_files):
"""The set to subtract. Pure.
A vector_store.file object's own `id` is the underlying Files API id, so
membership is one field rather than a join.
"""
out = set()
for item in store_files or []:
if isinstance(item, dict):
fid = str(item.get("id") or "")
if fid:
out.add(fid)
return out
def human(size):
"""Binary units, one decimal. Pure."""
try:
n = float(size)
except (TypeError, ValueError):
return "0 B"
for unit in ("B", "KiB", "MiB", "GiB", "TiB"):
if abs(n) < 1024 or unit == "TiB":
return "%d B" % int(n) if unit == "B" else "%.1f %s" % (n, unit)
n /= 1024.0
return "%.1f TiB" % n
def age_days(created_at, now):
"""Age in days. Pure. The clock is an argument. None when undatable."""
try:
created, at = int(created_at), int(now)
except (TypeError, ValueError):
return None
return (at - created) / 86400.0 if created > 0 else None
def class_state(rows, complete):
"""Grade the purpose class as a whole. Pure. Returns (state, detail)."""
if not complete:
return ("subtraction-unsafe",
"the referenced set is incomplete, so no file in this class "
"can be called an orphan")
if not rows:
return ("class-empty",
"no file carries purpose assistants or assistants_output, so "
"nothing was left behind here")
return ("class-populated",
"%d file(s) carry a purpose whose owning API no longer exists"
% len(rows))
def classify_file(row, referenced, complete, now):
"""Grade one file. Pure. Completeness is tested before anything else.
Deliberately first: when the referenced set is partial, a file that is in
a store the script could not read is indistinguishable from an orphan, and
the output of this script is a list of deletion commands.
"""
row = row if isinstance(row, dict) else {}
fid = str(row.get("id") or "")
if not complete:
return ("subtraction-incomplete",
"%s: at least one vector store could not be listed, so this "
"file cannot be called an orphan" % fid)
if fid in (referenced or set()):
return ("still-referenced",
"%s: held by a live vector store, so file search under the "
"Responses API still reads it" % fid)
age = age_days(row.get("created_at"), now)
when = ("created %.0f day(s) ago" % age) if age is not None else "undated"
if row.get("purpose") == "assistants_output":
return ("orphan-output",
"%s: code interpreter output from a run that no longer exists, "
"%s, %s" % (fid, human(row.get("size")), when))
return ("orphan",
"%s: no surviving vector store holds this id, %s, %s"
% (fid, human(row.get("size")), when))
def summarise(graded):
"""Fold graded rows into per-state counts and bytes. Pure."""
acc = {}
for state, row in graded or []:
cur = acc.setdefault(state, {"count": 0, "bytes": 0})
cur["count"] += 1
cur["bytes"] += int((row or {}).get("size") or 0)
return acc
def repair_lines(state, orphan_count=0, orphan_bytes=0, unreadable=()):
"""The repair for one verdict. Pure. Printed, never performed."""
if state in ("orphan", "orphan-output"):
return ["%d confirmed orphan(s), %s. Archive anything you still want, "
"then DELETE /v1/files/{file_id} one at a time. The delete "
"also removes the file from every vector store holding it."
% (orphan_count, human(orphan_bytes)),
"re-upload future file search sources with purpose user_data "
"and an expires_after policy, so the next class ages out on "
"its own."]
if state in ("subtraction-incomplete", "subtraction-unsafe"):
return ["%d vector store(s) could not be listed: %s. Re-run with a key "
"that can read them. A set difference against an incomplete "
"set names files that are perfectly well referenced."
% (len(unreadable), ", ".join(sorted(unreadable)) or "unknown")]
if state == "class-empty":
return []
return []
def get_page(url, params, key, timeout=30):
"""One GET. Returns (body, ok). A non-200 is a fact, not an exception."""
try:
r = requests.get(url, params=params,
headers={"Authorization": "Bearer " + key},
timeout=timeout)
except requests.RequestException as exc:
log.debug("GET %s failed: %s", url, exc)
return (None, False)
if r.status_code != 200:
log.debug("GET %s returned HTTP %s", url, r.status_code)
return (None, False)
try:
return (r.json(), True)
except ValueError:
return (None, False)
def walk(url, key, params, page_size, max_pages):
"""Page any of these listings on `after`. Returns (items, ok)."""
items, cursor, pages = [], None, 0
while pages < max_pages:
query = dict(params or {})
query["limit"] = page_size
if cursor:
query["after"] = cursor
body, ok = get_page(url, query, key)
if not ok:
return (items, False)
data = (body or {}).get("data") or []
pages += 1
items.extend(data)
if not data or (body or {}).get("has_more") is False:
return (items, True)
if "has_more" not in (body or {}) and len(data) < page_size:
return (items, True)
cursor = data[-1].get("id")
if not cursor:
return (items, True)
return (items, False)
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--max-pages", type=int, default=50,
help="page cap applied to every listing")
ap.add_argument("--show", type=int, default=25,
help="how many individual files to print")
args = ap.parse_args()
key = os.environ.get("OPENAI_API_KEY")
if not key:
log.error("set OPENAI_API_KEY to a project key set to Read Only. Every "
"call is a GET of /v1/files or /v1/vector_stores")
return 2
now = int(time.time())
rows = []
for purpose in PURPOSES:
items, ok = walk(FILES_URL, key, {"purpose": purpose, "order": "asc"},
FILE_PAGE, args.max_pages)
if not ok:
log.error("the %s listing could not be read in full; nothing can "
"be concluded from a partial class", purpose)
return 2
rows.extend(file_row(item) for item in items)
stores, stores_ok = walk(STORES_URL, key, {}, STORE_PAGE, args.max_pages)
referenced, unreadable = set(), []
for store in stores:
sid = str((store or {}).get("id") or "")
if not sid:
continue
items, ok = walk("%s/%s/files" % (STORES_URL, sid), key, {},
STORE_PAGE, args.max_pages)
referenced |= referenced_ids(items)
if not ok:
unreadable.append(sid)
complete = stores_ok and not unreadable
log.info("%d vector store(s) read, %d referenced file id(s)",
len(stores), len(referenced))
counts = {p: sum(1 for r in rows if r["purpose"] == p) for p in PURPOSES}
log.info("%d file(s) in the class: %d assistants, %d assistants_output, %s",
len(rows), counts["assistants"], counts["assistants_output"],
human(sum(r["size"] for r in rows)))
log.info(" measured: two purpose listings, minus the ids held by every "
"store read")
log.info(" inferred: that a file in no surviving store has no owner. The "
"migration guide documents nothing at all about files or vector "
"stores")
if not stores_ok:
log.warning(" the vector store listing itself was truncated or failed")
state, detail = class_state(rows, complete)
(log.warning if state == "subtraction-unsafe" else log.info)(
"%-20s %s", state, detail)
graded = [(classify_file(row, referenced, complete, now)[0], row)
for row in rows]
shown = 0
for row in rows:
verdict, line = classify_file(row, referenced, complete, now)
if shown < args.show:
(log.warning if verdict in FINDINGS else log.info)(
"%-20s %s", verdict, line)
shown += 1
totals = summarise(graded)
orphans = totals.get("orphan", {"count": 0, "bytes": 0})
outputs = totals.get("orphan-output", {"count": 0, "bytes": 0})
findings = sum(totals.get(s, {}).get("count", 0) for s in FINDINGS)
if orphans["count"] or outputs["count"]:
for line in repair_lines("orphan",
orphans["count"] + outputs["count"],
orphans["bytes"] + outputs["bytes"]):
log.warning(" repair: %s", line)
if not complete:
for line in repair_lines("subtraction-incomplete",
unreadable=unreadable):
log.warning(" repair: %s", line)
log.info("%d finding(s)", findings)
return 1 if findings else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Subtract the ids surviving vector stores hold from one dead purpose class.
*
* Read only. Four kinds of GET: two file listings, the vector store listing,
* and one file listing per store. Nothing is created and nothing is deleted.
*
* The Assistants API reached its shutdown date on 2026-08-26. Its objects
* went; the files they referenced did not. `assistants` and
* `assistants_output` are still valid values of `purpose` on the File object.
*
* The subtraction is only as good as the set being subtracted, so a store
* whose file listing could not be read downgrades every verdict in the run.
*/
const FILES_URL = 'https://api.openai.com/v1/files';
const STORES_URL = 'https://api.openai.com/v1/vector_stores';
export const PURPOSES = ['assistants', 'assistants_output'];
const FINDINGS = new Set(['orphan', 'orphan-output', 'subtraction-incomplete']);
// The files listing accepts up to 10,000 per page; both vector store listings
// cap at 100. Copying the first onto the second silently truncates the set.
const FILE_PAGE = 10000;
const STORE_PAGE = 100;
/** One file object, reduced. Pure. */
export function fileRow(body) {
const row = (body && typeof body === 'object') ? body : {};
const size = Number(row.bytes);
const created = Number(row.created_at ?? 0);
return {
id: String(row.id ?? ''),
filename: String(row.filename ?? ''),
size: Number.isFinite(size) ? Math.max(0, Math.trunc(size)) : 0,
purpose: String(row.purpose ?? ''),
created_at: Number.isFinite(created) ? Math.max(0, Math.trunc(created)) : 0,
};
}
/** The set to subtract. Pure. A store file's own id is the Files API id. */
export function referencedIds(storeFiles) {
const out = new Set();
for (const item of storeFiles ?? []) {
if (item && typeof item === 'object') {
const id = String(item.id ?? '');
if (id) out.add(id);
}
}
return out;
}
/** Binary units, one decimal. Pure. */
export function human(size) {
let n = Number(size);
if (!Number.isFinite(n)) return '0 B';
for (const unit of ['B', 'KiB', 'MiB', 'GiB', 'TiB']) {
if (Math.abs(n) < 1024 || unit === 'TiB') {
return unit === 'B' ? `${Math.trunc(n)} B` : `${n.toFixed(1)} ${unit}`;
}
n /= 1024;
}
return `${n.toFixed(1)} TiB`;
}
/** Age in days. Pure. The clock is an argument. Null when undatable. */
export function ageDays(createdAt, now) {
const created = Number(createdAt);
const at = Number(now);
if (!Number.isFinite(created) || !Number.isFinite(at) || created <= 0) return null;
return (at - created) / 86400;
}
/** Grade the purpose class as a whole. Pure. */
export function classState(rows, complete) {
if (!complete) {
return ['subtraction-unsafe',
'the referenced set is incomplete, so no file in this class can be called '
+ 'an orphan'];
}
if (!(rows ?? []).length) {
return ['class-empty',
'no file carries purpose assistants or assistants_output, so nothing was '
+ 'left behind here'];
}
return ['class-populated',
`${rows.length} file(s) carry a purpose whose owning API no longer exists`];
}
/** Grade one file. Pure. Completeness is tested before anything else. */
export function classifyFile(row, referenced, complete, now) {
const file = (row && typeof row === 'object') ? row : {};
const id = String(file.id ?? '');
if (!complete) {
return ['subtraction-incomplete',
`${id}: at least one vector store could not be listed, so this file cannot `
+ 'be called an orphan'];
}
if ((referenced ?? new Set()).has(id)) {
return ['still-referenced',
`${id}: held by a live vector store, so file search under the Responses API `
+ 'still reads it'];
}
const age = ageDays(file.created_at, now);
const when = age === null ? 'undated' : `created ${age.toFixed(0)} day(s) ago`;
if (file.purpose === 'assistants_output') {
return ['orphan-output',
`${id}: code interpreter output from a run that no longer exists, `
+ `${human(file.size)}, ${when}`];
}
return ['orphan',
`${id}: no surviving vector store holds this id, ${human(file.size)}, ${when}`];
}
/** Fold graded rows into per-state counts and bytes. Pure. */
export function summarise(graded) {
const acc = {};
for (const [state, row] of graded ?? []) {
const cur = acc[state] ?? { count: 0, bytes: 0 };
cur.count += 1;
cur.bytes += Number(row?.size ?? 0);
acc[state] = cur;
}
return acc;
}
/** The repair for one verdict. Pure. Printed, never performed. */
export function repairLines(state, orphanCount = 0, orphanBytes = 0, unreadable = []) {
if (state === 'orphan' || state === 'orphan-output') {
return [`${orphanCount} confirmed orphan(s), ${human(orphanBytes)}. Archive `
+ 'anything you still want, then DELETE /v1/files/{file_id} one at a time. '
+ 'The delete also removes the file from every vector store holding it.',
're-upload future file search sources with purpose user_data and an '
+ 'expires_after policy, so the next class ages out on its own.'];
}
if (state === 'subtraction-incomplete' || state === 'subtraction-unsafe') {
return [`${(unreadable ?? []).length} vector store(s) could not be listed: `
+ `${[...(unreadable ?? [])].sort().join(', ') || 'unknown'}. Re-run with a `
+ 'key that can read them. A set difference against an incomplete set '
+ 'names files that are perfectly well referenced.'];
}
return [];
}
async function getPage(url, params, key) {
const target = new URL(url);
for (const [k, v] of Object.entries(params ?? {})) {
target.searchParams.set(k, String(v));
}
try {
const res = await fetch(target, { headers: { Authorization: `Bearer ${key}` } });
if (res.status !== 200) return [null, false];
return [await res.json().catch(() => null), true];
} catch {
return [null, false];
}
}
async function walk(url, key, params, pageSize, maxPages) {
const items = [];
let cursor = null;
let pages = 0;
while (pages < maxPages) {
const query = { ...(params ?? {}), limit: pageSize };
if (cursor) query.after = cursor;
const [body, ok] = await getPage(url, query, key);
if (!ok) return [items, false];
const data = (body ?? {}).data ?? [];
pages += 1;
items.push(...data);
if (!data.length || (body ?? {}).has_more === false) return [items, true];
if (!('has_more' in (body ?? {})) && data.length < pageSize) return [items, true];
cursor = data[data.length - 1]?.id;
if (!cursor) return [items, true];
}
return [items, false];
}
function args(argv) {
const out = { maxPages: 50, show: 25 };
for (let i = 0; i < argv.length; i += 1) {
if (argv[i] === '--max-pages') out.maxPages = Number.parseInt(argv[i += 1], 10);
else if (argv[i] === '--show') out.show = Number.parseInt(argv[i += 1], 10);
}
return out;
}
async function main() {
const opts = args(process.argv.slice(2));
const key = process.env.OPENAI_API_KEY;
if (!key) {
console.error('set OPENAI_API_KEY to a project key set to Read Only. Every '
+ 'call is a GET of /v1/files or /v1/vector_stores');
process.exitCode = 2;
return;
}
const now = Math.trunc(Date.now() / 1000);
const rows = [];
for (const purpose of PURPOSES) {
const [items, ok] = await walk(FILES_URL, key, { purpose, order: 'asc' },
FILE_PAGE, opts.maxPages);
if (!ok) {
console.error(`the ${purpose} listing could not be read in full; nothing `
+ 'can be concluded from a partial class');
process.exitCode = 2;
return;
}
for (const item of items) rows.push(fileRow(item));
}
const [stores, storesOk] = await walk(STORES_URL, key, {}, STORE_PAGE, opts.maxPages);
const referenced = new Set();
const unreadable = [];
for (const store of stores) {
const id = String(store?.id ?? '');
if (!id) continue;
const [items, ok] = await walk(`${STORES_URL}/${id}/files`, key, {},
STORE_PAGE, opts.maxPages);
for (const fid of referencedIds(items)) referenced.add(fid);
if (!ok) unreadable.push(id);
}
const complete = storesOk && !unreadable.length;
console.log(`${stores.length} vector store(s) read, ${referenced.size} `
+ 'referenced file id(s)');
const counts = Object.fromEntries(PURPOSES.map((p) =>
[p, rows.filter((r) => r.purpose === p).length]));
console.log(`${rows.length} file(s) in the class: ${counts.assistants} assistants, `
+ `${counts.assistants_output} assistants_output, `
+ `${human(rows.reduce((a, r) => a + r.size, 0))}`);
console.log(' measured: two purpose listings, minus the ids held by every store read');
console.log(' inferred: that a file in no surviving store has no owner. The '
+ 'migration guide documents nothing at all about files or vector stores');
if (!storesOk) console.log(' the vector store listing itself was truncated or failed');
const [state, detail] = classState(rows, complete);
console.log(`${state.padEnd(20)} ${detail}`);
const graded = rows.map((row) => [classifyFile(row, referenced, complete, now)[0], row]);
let shown = 0;
for (const row of rows) {
const [verdict, line] = classifyFile(row, referenced, complete, now);
if (shown < opts.show) {
console.log(`${verdict.padEnd(20)} ${line}`);
shown += 1;
}
}
const tot = summarise(graded);
const orphans = tot.orphan ?? { count: 0, bytes: 0 };
const outputs = tot['orphan-output'] ?? { count: 0, bytes: 0 };
let findings = 0;
for (const s of FINDINGS) findings += tot[s]?.count ?? 0;
if (orphans.count || outputs.count) {
for (const line of repairLines('orphan', orphans.count + outputs.count,
orphans.bytes + outputs.bytes)) {
console.log(` repair: ${line}`);
}
}
if (!complete) {
for (const line of repairLines('subtraction-incomplete', 0, 0, unreadable)) {
console.log(` repair: ${line}`);
}
}
console.log(`${findings} finding(s)`);
process.exitCode = findings ? 1 : 0;
}
if (import.meta.url === `file://${process.argv[1]}`) await main();
Add a test
The third test is the one the whole script is built around, so read that one first: a single unreadable vector store has to turn every orphan in the run into subtraction-incomplete, and the same file that would otherwise be a deletion candidate must come back as one that cannot be called an orphan. Before it, the two ordinary outcomes: a file no store holds, graded separately depending on whether a person uploaded it or the code interpreter emitted it, and a file a live store still holds, which is not a finding and gets no repair. Then referenced_ids, asserted to read the vector-store file's own id and to survive a listing full of junk. Then the empty purpose class, which is a real answer rather than a blank. And last the repair lines, which have to name the vector-store side effect of a delete in the same sentence as the delete.
from openai_orphaned_assistant_files import (age_days, class_state,
classify_file, file_row, human,
referenced_ids, repair_lines,
summarise)
NOW = 1_800_000_000
DAY = 86400
def f(fid, size=1024, purpose="assistants", days_old=500):
return file_row({"id": fid, "bytes": size, "purpose": purpose,
"filename": fid + ".pdf",
"created_at": NOW - int(days_old * DAY)})
def test_a_file_no_surviving_store_holds_is_the_finding():
row = f("file-3ab", 43_200_512, days_old=511)
state, detail = classify_file(row, set(), True, NOW)
assert state == "orphan"
assert "no surviving vector store holds this id" in detail
assert "41.2 MiB" in detail and "511 day(s) ago" in detail
lines = repair_lines(state, 1, 43_200_512)
assert any("DELETE /v1/files/{file_id}" in line for line in lines)
assert any("every vector store holding it" in line for line in lines)
def test_platform_generated_output_is_its_own_state():
row = f("file-b19", 120_832, purpose="assistants_output", days_old=502)
state, detail = classify_file(row, set(), True, NOW)
assert state == "orphan-output"
assert "code interpreter output" in detail
assert "no longer exists" in detail
# A file a live store still holds is not a finding at all.
held, held_detail = classify_file(f("file-c04"), {"file-c04"}, True, NOW)
assert held == "still-referenced"
assert "still reads it" in held_detail
assert repair_lines(held) == []
def test_one_unreadable_store_downgrades_every_verdict_in_the_run():
row = f("file-3ab")
# With a complete set this is a deletion candidate.
assert classify_file(row, set(), True, NOW)[0] == "orphan"
# With an incomplete one it is not, and the reason is stated.
state, detail = classify_file(row, set(), False, NOW)
assert state == "subtraction-incomplete"
assert "could not be listed" in detail
assert "cannot be called an orphan" in detail
# Not even a file that really is referenced escapes the downgrade, because
# the script cannot tell the two apart once the set is partial.
assert classify_file(f("file-c04"), {"file-c04"}, False, NOW)[0] \
== "subtraction-incomplete"
assert class_state([row], False)[0] == "subtraction-unsafe"
lines = repair_lines("subtraction-incomplete", unreadable=["vs_b2", "vs_a1"])
assert "vs_a1, vs_b2" in lines[0]
assert "perfectly well referenced" in lines[0]
def test_referenced_ids_reads_the_store_files_own_id():
ids = referenced_ids([{"id": "file-c04", "object": "vector_store.file",
"vector_store_id": "vs_a1", "status": "completed"},
{"id": "file-d15", "status": "failed"},
{"id": ""}, None, "not-an-object", {}])
assert ids == {"file-c04", "file-d15"}
assert referenced_ids(None) == set()
# A failed attach still holds the id, so the file is still referenced.
assert classify_file(f("file-d15"), ids, True, NOW)[0] == "still-referenced"
def test_an_empty_purpose_class_is_an_answer_and_not_a_blank():
state, detail = class_state([], True)
assert state == "class-empty"
assert "nothing was left behind" in detail
assert repair_lines(state) == []
full, full_detail = class_state([f("file-1"), f("file-2")], True)
assert full == "class-populated"
assert "2 file(s)" in full_detail
assert "no longer exists" in full_detail
def test_the_folds_and_the_formatting_survive_junk():
graded = [("orphan", f("file-1", 1024)),
("orphan", f("file-2", 2048)),
("still-referenced", f("file-3", 4096))]
assert summarise(graded)["orphan"] == {"count": 2, "bytes": 3072}
assert summarise([])== {}
assert file_row(None)["id"] == ""
assert file_row({"bytes": "nope", "created_at": "nope"})["size"] == 0
assert age_days(0, NOW) is None and age_days("x", NOW) is None
assert human(1024) == "1.0 KiB" and human(None) == "0 B"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ageDays, classState, classifyFile, fileRow, human, referencedIds,
repairLines, summarise } from './openai-orphaned-assistant-files.mjs';
const NOW = 1_800_000_000;
const DAY = 86400;
const f = (id, size = 1024, purpose = 'assistants', daysOld = 500) => fileRow({
id, bytes: size, purpose, filename: `${id}.pdf`,
created_at: NOW - Math.trunc(daysOld * DAY),
});
test('a file no surviving store holds is the finding', () => {
const row = f('file-3ab', 43200512, 'assistants', 511);
const [state, detail] = classifyFile(row, new Set(), true, NOW);
assert.equal(state, 'orphan');
assert.ok(detail.includes('no surviving vector store holds this id'));
assert.ok(detail.includes('41.2 MiB') && detail.includes('511 day(s) ago'));
const lines = repairLines(state, 1, 43200512);
assert.ok(lines.some((l) => l.includes('DELETE /v1/files/{file_id}')));
assert.ok(lines.some((l) => l.includes('every vector store holding it')));
});
test('platform generated output is its own state', () => {
const row = f('file-b19', 120832, 'assistants_output', 502);
const [state, detail] = classifyFile(row, new Set(), true, NOW);
assert.equal(state, 'orphan-output');
assert.ok(detail.includes('code interpreter output'));
assert.ok(detail.includes('no longer exists'));
const [held, heldDetail] = classifyFile(f('file-c04'), new Set(['file-c04']), true, NOW);
assert.equal(held, 'still-referenced');
assert.ok(heldDetail.includes('still reads it'));
assert.deepEqual(repairLines(held), []);
});
test('one unreadable store downgrades every verdict in the run', () => {
const row = f('file-3ab');
assert.equal(classifyFile(row, new Set(), true, NOW)[0], 'orphan');
const [state, detail] = classifyFile(row, new Set(), false, NOW);
assert.equal(state, 'subtraction-incomplete');
assert.ok(detail.includes('could not be listed'));
assert.ok(detail.includes('cannot be called an orphan'));
assert.equal(classifyFile(f('file-c04'), new Set(['file-c04']), false, NOW)[0],
'subtraction-incomplete');
assert.equal(classState([row], false)[0], 'subtraction-unsafe');
const lines = repairLines('subtraction-incomplete', 0, 0, ['vs_b2', 'vs_a1']);
assert.ok(lines[0].includes('vs_a1, vs_b2'));
assert.ok(lines[0].includes('perfectly well referenced'));
});
test('referencedIds reads the store files own id', () => {
const ids = referencedIds([{ id: 'file-c04', object: 'vector_store.file',
vector_store_id: 'vs_a1', status: 'completed' },
{ id: 'file-d15', status: 'failed' },
{ id: '' }, null, 'not-an-object', {}]);
assert.deepEqual([...ids].sort(), ['file-c04', 'file-d15']);
assert.equal(referencedIds(null).size, 0);
assert.equal(classifyFile(f('file-d15'), ids, true, NOW)[0], 'still-referenced');
});
test('an empty purpose class is an answer and not a blank', () => {
const [state, detail] = classState([], true);
assert.equal(state, 'class-empty');
assert.ok(detail.includes('nothing was left behind'));
assert.deepEqual(repairLines(state), []);
const [full, fullDetail] = classState([f('file-1'), f('file-2')], true);
assert.equal(full, 'class-populated');
assert.ok(fullDetail.includes('2 file(s)'));
assert.ok(fullDetail.includes('no longer exists'));
});
test('the folds and the formatting survive junk', () => {
const graded = [['orphan', f('file-1', 1024)],
['orphan', f('file-2', 2048)],
['still-referenced', f('file-3', 4096)]];
assert.deepEqual(summarise(graded).orphan, { count: 2, bytes: 3072 });
assert.deepEqual(summarise([]), {});
assert.equal(fileRow(null).id, '');
assert.equal(fileRow({ bytes: 'nope', created_at: 'nope' }).size, 0);
assert.equal(ageDays(0, NOW), null);
assert.equal(ageDays('x', NOW), null);
assert.equal(human(1024), '1.0 KiB');
assert.equal(human(null), '0 B');
});
FAQ
Does OpenAI actually document that files survive the Assistants shutdown?
No, and this is the one place the note deliberately stops short of the research it started from. The migration guide covers assistants, threads and runs; it does not contain the words file, vector store or purpose anywhere. So the survival of these files is something you observe rather than something you can cite. What is documented is enough to build the check: assistants and assistants_output are still enumerated as valid purposes on the File object, vector stores are a live non-beta resource with their own reference, and the delete call is documented to remove a file from all vector stores. The script prints its inference on its own labelled line so the two never get confused.
What is the difference between this and the note on the Assistants shutdown?
That one asks whether GET /v1/assistants still answers for your organization, grades the status code against a control path on the same key, and its finding is that you still have grace access to an API that is over. It is a question about access, and its answer changes from week to week. This one assumes the endpoint is gone and asks what it left behind. It would produce the same findings on an organization that migrated cleanly two years ago, because storage does not care when you migrated.
Why does one unreadable vector store invalidate the whole run?
Because the output of this script is a list of files to delete, and a file that lives in a store the script could not read is indistinguishable from a file that lives in no store at all. Skipping the unreadable store would quietly move its contents into the orphan column. So the completeness test runs before every other test in the classifier, the whole run is graded subtraction-incomplete, and no deletion command is printed. There is a test that fails if that ordering is ever changed.
Some of these files are attached to a store but the attach failed. Are they orphans?
No, and the script counts them as referenced on purpose. A vector_store.file object with status failed still carries the file id, so the file is still owned by a store, still deletable through that store, and still visible to whoever is fixing the ingest. Deleting it as an orphan would remove the evidence somebody needs. The failed attach is its own problem and there is a separate note for it.
What should we upload these as in future?
purpose user_data, with an expires_after policy set at upload time. The assistants purpose was tied to an API that no longer exists, so anything still using it is writing into a class with no owner from the first second. user_data is the general-purpose value, and an expiry means the next accumulation ages out on its own rather than waiting for somebody to write a script like this one in two years.
Related field notes
- Whether the endpoint that owned these files still answers you
- The same store read as one number against a ceiling
- A file that is in a store and still missing from every search
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.
- Assistants API migration guide — OpenAI platform docs
- Deprecations — OpenAI platform docs
- Files — OpenAI API reference
- Vector store files — 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.