Skip to content

Diagnostic LLM APIs

The Assistants API is shut down. Is yours still answering?

The pipeline has not run since Wednesday and the error is a 404 on a path that has been in the codebase for two years. Somebody checks the model id first, because that is what a 404 usually means, and the model id is fine. It is fine because the model was never the problem: the whole /v1/assistants family reached its published shutdown date on 26 August 2026 and the endpoint is simply not there any more. The uncomfortable part comes an hour later, when the same probe run against the staging organization returns 200, and now you have two organizations, one dead API, and a question nobody wants to answer about how long the other one has.

Read-only key Python and Node.js Tests included
Paying with a phone
Photo by CardMapr.nl on Unsplash
The short answer

Read the polarity backwards, because the date is in the past. Every other deprecation note in this section treats a 404 as the alarm. Here a 404 is the documented, expected answer and is not a finding at all. The finding is a 200: an organization that still gets a list object back from GET /v1/assistants five days after the shutdown is running on grace, not on support, and the next thing that changes is not announced.

One status code cannot carry that, because a 404 from a closed path and a 404 from a key that reads nothing are the same number. So the script grades a pair: the subject path /v1/assistants and a control path /v1/models, on the same key, with the same headers, in the same run. What varies is the path and only the path. Control 200 with subject 404 is a real closure. Control not-200 makes the whole reading worthless and the script says so instead of grading the rest.

A probe cannot date anything. It tells you what the endpoint does now, not when it stopped, and "when" is the question that separates a shutdown from a bad deploy on the same afternoon. That comes from a second, weaker reading with an admin-read key: GET /v1/organization/usage/completions with bucket_width=1d and group_by[]=project_id over the last 30 days. A project whose num_model_requests falls to zero on 2026-08-26 specifically lost its traffic to the shutdown. One that fell to zero on the 19th lost it to something you did.

The script keeps those two readings apart on the page and in the output, because one is a measurement and the other is an inference. It also refuses the flattering middle case: a project that also serves other traffic shows a dip on the date, not a cliff, and a dip is reported as a dip.

The repair is a rewrite, not a swap. Assistants, threads, runs and run steps become POST /v1/responses carrying a conversation id from the Conversations API, and the OpenAI-Beta: assistants=v2 header goes away entirely. There is no model id to change, which is exactly why the model id was the wrong thing to check first.

The problem in plain words

The Assistants API was announced for retirement on 26 August 2025 with a full year of notice and shut down on 26 August 2026. It was not one endpoint. Assistants, threads, messages, runs and run steps were a whole object model, and code written against it does not resemble the thing that replaced it: the Responses API plus the Conversations API keep the state but not the shape. So the migration is a rewrite of a call graph, which is why a year was not obviously too much notice and why some organizations still have not done it.

What makes it a field note rather than a calendar entry is that the shutdown does not land everywhere at once. Access after a published date is a matter of what the provider is still routing for whom, and organizations discover on different days. That produces the specific, miserable situation this note exists for: production is 404ing, staging is fine, and the difference between them is not a config value anybody can find. It is grace, and grace has no expiry you can read.

The failure is also badly disguised. A 404 on /v1/threads/{id}/runs looks exactly like a mistyped id, exactly like a deleted thread, and exactly like a model that does not exist — the SDKs raise the same NotFoundError for all of them. The first hour of the investigation goes into the id, because ids are what 404s are usually about. Nothing in the error body says "this API is over".

And the aggregate hides the moment it happened. Assistants runs billed as ordinary model requests, so a project's usage report shows the outage as a number going to zero among other numbers that did not, on a day that is not labelled. Nobody reads a usage report looking for a cliff unless somebody has already suggested there is one.

Runs callthreadsthe shape of theold APIShutdown datepassesnothing isdeployed404 on everycallsame class as atypoModel idchecked firstand it is fineStaging stillanswersso the theorybreaks
Every step is the reasonable one. The hour is spent on the id because a 404 is almost always about an id.

Why it happens

Past the date, a 200 is the finding and a 404 is the baseline. This is the only note in the batch with that polarity and it changes what the script is for. It is not detecting a problem you already have; production told you about that. It is detecting the problem you do not have yet, in the organization that still answers, where nobody is looking because nothing is broken.

A control path is what makes one 404 mean anything. Hold the credential, the headers and the host fixed, vary only the path, and the pair separates a closed endpoint from a key that cannot read. Without it a revoked key, a project-scoped credential and a shut-down API all produce the same evidence, and the script would confidently report a shutdown it has not observed.

Dating an outage is a different question from observing one, and needs a different key. The probe answers "does it answer". Only the usage report answers "when did it stop", and it needs an admin-read credential the project key does not have. The script runs whichever readings it can and labels the ones it could not, because a missing admin key is a gap in coverage rather than a clean bill of health.

A dip is not a cliff, and calling it one would be a lie the script cannot detect later. A project that ran assistants alongside ordinary completions loses part of its traffic on the date, not all of it. That is still evidence and it is weaker evidence, so it comes back as its own state with the reason attached. The temptation to round it up to a cliff is exactly how an inference gets published as a measurement.

This note owns an endpoint family, not a model id. There are published notes that read shutdown_date off the model object and that diff your configured model strings against the model list. Neither one can see this: no model was retired, every id involved still resolves, and the thing that disappeared has no entry in GET /v1/models to check. That is the whole reason a surface closure needs its own note.

The repair is a rewrite and the script does not pretend otherwise. There is no successor id to substitute. What gets printed is the shape of the change — runs become responses, threads become conversations, the beta header is deleted — plus the projects that still have traffic to move. A one-line diff would be the wrong output because it would be a false description of the work.

The fix, as a flow

Past a published shutdown date the usual reading runs backwards. A 404 is the documented answer and tells you nothing you did not already know; a 200 is the finding, because it means this organization is still being served an API that is over. And one status code cannot carry either claim, since a closed path and a key that reads nothing produce the same number. So the unit is a pair of paths on one credential, and the date the traffic actually stopped is a separate, weaker reading kept separate on purpose.

Subject and control pathsame key, same headersControl 200, subject 404closed, as publishedControl 200, subject 200still on grace, and undatedControl not 200nothing was proved at allRequests end on the datethe closure, not a deployRequests end elsewheresomething you did
The path is the only thing that varies. Read the outcomes with the polarity inverted: the 404 row is the calm one.

How to fix it

Use a project read key, and know what each key can see

OPENAI_API_KEY reads the two listings. It cannot read organization usage, so the second half of the check needs OPENAI_ADMIN_KEY with api.usage.read. Run with only the project key and you learn whether the endpoint answers; add the admin key and you learn when it stopped. The script prints which of the two it did.

Probe the subject path and the control path in one run

GET /v1/assistants?limit=1 and GET /v1/models?limit=1, same key, same headers. Do not raise on the 404 — it is the answer. Record the status and, where there is one, the object field on a 200 or the error.code on a failure.

Grade the pair, not either status

Control 200 plus subject 404 is a confirmed closure. Control 200 plus subject 200 is grace access and is the finding worth acting on. Control anything else means the reading is void, and the script stops rather than reporting a shutdown it did not see.

Date the outage from the daily usage buckets

GET /v1/organization/usage/completions?bucket_width=1d&group_by[]=project_id over 30 days. Fold the buckets into one series of num_model_requests per project per day, then look for the step and check whether it lands on 2026-08-26.

Print the rewrite, and print what was not proved

Per project: the shape of the migration, and whether the evidence is a cliff, a dip, or nothing. For an organization still answering, print the sentence that matters — the date has passed, so this access is not a supported state and has no readable expiry.

How to check it worked

After the migration, re-run. The subject probe should not change at all, because it never described your code; what should change is the usage series, which now shows the project's requests recovering on the day the Responses path shipped. The reading that will not improve is grace access, and it should not: the only thing that closes that finding is having nothing left behind the endpoint.

OPENAI_API_KEY=sk-proj-... OPENAI_ADMIN_KEY=sk-admin-... \
  python3 assistants_shutdown_probe.py --days 30
# shutdown 2026-08-26, 5 day(s) past
#   control  GET /v1/models      200  answering    200, and the response is list
#   subject  GET /v1/assistants  404  gone         404 model_not_found, which is what a
#                                                  closed path returns
# shut-down            the control path answers and the subject path does not, so this
#                      organization is past the 2026-08-26 shutdown
#   repair: runs become POST /v1/responses carrying a conversation id, threads become
#           POST /v1/conversations, and the OpenAI-Beta: assistants=v2 header is deleted
# proj_ab12            cliff-on-the-date  1,204 requests/day until 2026-08-25 and 0 from
#                      2026-08-26, which is the shutdown and not a deploy
# proj_cd34            dip-on-the-date    requests fell to 18% of the prior mean on
#                      2026-08-26, so part of this project was assistants traffic
# 3 finding(s)

The full code

Two GETs, one optional report, and five pure functions. days_past, which is arithmetic against a published constant because no endpoint returns the date; probe_state, which says what one status means in isolation and refuses to say more, including the 429 case where a refusal proves the path is still routing; access_verdict, the only function that looks at both paths at once and the only one that can say the word shutdown; cliff_verdict, which grades a daily series into a cliff, a dip, or an admission that it cannot tell; and repair_lines, which prints the shape of a rewrite and never a model id, because there is not one.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 97 LLM API fixes, free and open source.
assistants_shutdown_probe.py
"""Probe an endpoint family that is already past its published shutdown date.

Read only. Every request is a GET: the assistants listing, a control listing of
models on the same key, and the organization usage report. Nothing here creates
an assistant, a thread or a run, and a 404 from a listing costs exactly as
little as a 200.

Past a shutdown date the polarity inverts. A 404 is the documented, expected
answer and is not the finding; a 200 is, because it means this organization
still has grace access to an API that is over. A 404 on its own cannot tell a
closed path from a key that reads nothing, so the unit here is a pair: the
subject path against a control path on the same credential, with the path as
the only thing that varies.

The probe measures whether the endpoint answers you today. It cannot date an
outage. That is what the usage report is for, and the two are reported
separately because one is a measurement and the other is an inference.
"""
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("assistants_shutdown_probe")

API = "https://api.openai.com/v1"
SUBJECT = "/assistants"
CONTROL = "/models"

# Announced 26 August 2025 with a year of notice; assistants, threads, messages,
# runs and run steps were replaced by the Responses API plus the Conversations
# API. The date is published and not readable -- no endpoint returns it, and
# nothing in GET /v1/models can see a path that no longer exists -- so it is a
# constant here and the note says where it came from.
SHUTDOWN = "2026-08-26"

# Live means the path still routes. A 429 is a refusal from something that
# exists, which is not the same as a 404 from something that does not.
LIVE = ("answering", "throttled")

FINDINGS = ("grace-access", "shut-down", "closed-early", "control-failed",
            "unreadable", "cliff-on-the-date", "dip-on-the-date")

REPAIRS = {
    "grace-access":
        "this organization still reaches an API that shut down on "
        + SHUTDOWN + ". That is grace, not support, and it has no expiry you "
        "can read. Move it now: runs become POST /v1/responses carrying a "
        "conversation id, threads become POST /v1/conversations, and the "
        "OpenAI-Beta header is deleted.",
    "shut-down":
        "runs become POST /v1/responses carrying a conversation id from "
        "POST /v1/conversations, and the OpenAI-Beta: assistants=v2 header is "
        "deleted. There is no model id to swap here, which is why checking the "
        "model id first never helps.",
    "closed-early":
        "the path is already gone and the published date has not arrived. "
        "Treat the date as the outside edge rather than the schedule.",
    "control-failed":
        "the control path did not answer either, so nothing was proved about "
        "the subject path. Fix the credential or the network and re-run before "
        "reading anything else here.",
    "cliff-on-the-date":
        "this project's traffic stopped on the shutdown date, so the outage is "
        "the closure and not a deploy. Migrate this project first: it has the "
        "most to move.",
    "dip-on-the-date":
        "part of this project's traffic stopped on the shutdown date. The "
        "project serves other work as well, so the assistants share is what "
        "needs migrating, not the whole project.",
}


def days_past(today, when=SHUTDOWN):
    """Whole days from a published date to today. Pure. Negative before it."""
    return (dt.date.fromisoformat(str(today))
            - dt.date.fromisoformat(str(when))).days


def probe_state(status, body=None):
    """What one listing's status means on its own. Pure. Returns (state, why).

    On its own is the operative phrase. A 404 from a path that is supposed to
    be gone and a 404 from a key that cannot see it are the same number, and
    only the pair in access_verdict() separates them.
    """
    if status is None:
        return ("unreachable", "no response at all from this path")
    status = int(status)
    body = body if isinstance(body, dict) else {}
    if status == 200:
        kind = body.get("object") or "a body with no object field"
        return ("answering", "200, and the response is %s" % kind)
    err = body.get("error") if isinstance(body.get("error"), dict) else {}
    code = err.get("code") or err.get("type") or "no error code"
    if status == 404:
        return ("gone", "404 %s, which is what a closed path returns" % code)
    if status in (401, 403):
        return ("credentials",
                "%d %s, so this probe says nothing about the path"
                % (status, code))
    if status == 429:
        return ("throttled",
                "429 %s, which is a refusal from a path that still routes"
                % code)
    return ("refused", "%d %s" % (status, code))


def access_verdict(subject, control, past):
    """Grade the subject path against the control path. Pure. (state, why).

    The only function here that looks at both paths at once, and the only one
    entitled to use the word shutdown. Everything upstream of it describes a
    single status code and stops.
    """
    if control not in LIVE:
        return ("control-failed",
                "the control path came back %s, so this key proves nothing "
                "about the subject path" % control)
    if subject in LIVE:
        if past >= 0:
            return ("grace-access",
                    "the subject path answered %d day(s) after its published "
                    "shutdown date, which is access on grace rather than a "
                    "supported state" % past)
        return ("still-open",
                "the subject path answers and the shutdown is %d day(s) away"
                % -past)
    if subject == "gone":
        if past >= 0:
            return ("shut-down",
                    "the control path answers and the subject path does not, "
                    "so this organization is past the %s shutdown" % SHUTDOWN)
        return ("closed-early",
                "the subject path is already gone with %d day(s) still to run "
                "on the published date" % -past)
    return ("unreadable",
            "the subject path came back %s, which is neither an answer nor a "
            "closure" % subject)


def cliff_verdict(series, when=SHUTDOWN):
    """Grade a daily [(date, requests)] series. Pure. Returns (state, why).

    Dates an outage, or declines to. A project that served only assistants
    traffic goes to zero on the date; one that served other work as well shows
    a step down, and a step down is reported as a step down. Rounding the
    second case up to the first is how an inference gets published as a fact.
    """
    rows = sorted((str(d), float(n or 0)) for d, n in (series or []))
    if not rows:
        return ("not-checked",
                "no usage buckets were read, so the outage could not be dated")
    before = [n for d, n in rows if d < str(when)]
    after = [n for d, n in rows if d >= str(when)]
    if not before or not after:
        return ("window-too-short",
                "the window does not span %s, so there is nothing to compare "
                "across it" % when)
    mean_before = sum(before) / len(before)
    mean_after = sum(after) / len(after)
    if mean_before == 0:
        return ("no-traffic-in-window",
                "this project had no requests before %s either, so there is "
                "no outage here to explain" % when)
    if mean_after == 0:
        last_live = max((d for d, n in rows if n > 0), default=None)
        eve = (dt.date.fromisoformat(str(when))
               - dt.timedelta(days=1)).isoformat()
        if last_live == eve:
            return ("cliff-on-the-date",
                    "%.0f requests/day until %s and none from %s, which is the "
                    "shutdown and not a deploy"
                    % (mean_before, last_live, when))
        return ("cliff-elsewhere",
                "traffic stopped, but the last live day is %s rather than %s, "
                "the day before %s" % (last_live, eve, when))
    share = mean_after / mean_before
    if share <= 0.5:
        return ("dip-on-the-date",
                "requests fell to %.0f%% of the prior mean on %s, so part of "
                "this project was assistants traffic and part was not"
                % (share * 100, when))
    return ("still-running",
            "requests continued across %s at %.0f%% of the prior mean"
            % (when, share * 100))


def repair_lines(state):
    """The repair for one verdict. Pure. Printed, never performed."""
    line = REPAIRS.get(state)
    if not line:
        return []
    if state in ("grace-access", "shut-down"):
        return [line,
                "the migration guide is Migrate to the Responses API. There is "
                "no successor model id, so no config change closes this."]
    return [line]


def get_json(session, base, path, key, params=None, timeout=30):
    """One GET. Returns (status, parsed body). Never raises on a 4xx."""
    try:
        r = session.get(base + 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 usage_series(session, key, days):
    """{project_id: [(date, requests)]} from the daily usage report."""
    start = int((dt.datetime.now(dt.timezone.utc)
                 - dt.timedelta(days=days)).timestamp())
    params = {"start_time": start, "bucket_width": "1d",
              "group_by[]": ["project_id"], "limit": max(7, min(days, 180))}
    status, body = get_json(session, API, "/organization/usage/completions",
                            key, params)
    if status != 200:
        log.warning("usage report came back %s, so no outage can be dated",
                    status)
        return {}
    out = {}
    for bucket in body.get("data") or []:
        stamp = bucket.get("start_time")
        if not stamp:
            continue
        day = dt.datetime.fromtimestamp(int(stamp), dt.timezone.utc).date().isoformat()
        for row in bucket.get("results") or []:
            pid = row.get("project_id") or "(unattributed)"
            out.setdefault(pid, []).append((day, row.get("num_model_requests") or 0))
    return out


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--days", type=int, default=30,
                    help="days of daily usage 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

    past = days_past(args.today)
    log.info("shutdown %s, %d day(s) %s", SHUTDOWN, abs(past),
             "past" if past >= 0 else "away")

    session = requests.Session()
    states = {}
    for role, path in (("control", CONTROL), ("subject", SUBJECT)):
        status, body = get_json(session, API, path, key, {"limit": 1})
        state, why = probe_state(status, body)
        states[role] = state
        emit = log.warning if role == "subject" and state in LIVE else log.info
        emit("  %-8s GET /v1%-12s %s  %-12s %s", role, path,
             "---" if status is None else status, state, why)

    findings = 0
    state, why = access_verdict(states["subject"], states["control"], past)
    emit = log.warning if state in FINDINGS else log.info
    emit("%-20s %s", state, why)
    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("%-20s no admin key, so the outage was observed and not dated",
                 "not-dated")
    else:
        series = usage_series(session, admin, args.days)
        if not series:
            log.info("%-20s the usage report returned nothing to date it with",
                     "not-dated")
        for pid, rows in sorted(series.items()):
            state, why = cliff_verdict(rows)
            emit = log.warning if state in FINDINGS else log.info
            emit("%-20s %-18s %s", pid, state, why)
            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())
assistants-shutdown-probe.mjs
/**
 * Probe an endpoint family that is already past its published shutdown date.
 *
 * Read only. Every request is a GET: the assistants listing, a control listing
 * of models on the same key, and the organization usage report. Nothing here
 * creates an assistant, a thread or a run.
 *
 * Past a shutdown date the polarity inverts: a 404 is the expected answer and
 * a 200 is the finding. A 404 on its own cannot tell a closed path from a key
 * that reads nothing, so the unit is a pair with the path as the only variable.
 */
export const API = 'https://api.openai.com/v1';
export const SUBJECT = '/assistants';
export const CONTROL = '/models';

// Announced 26 August 2025 with a year of notice. Published, not readable.
export const SHUTDOWN = '2026-08-26';

// A 429 is a refusal from something that exists, which is not a 404.
const LIVE = new Set(['answering', 'throttled']);

const FINDINGS = new Set(['grace-access', 'shut-down', 'closed-early',
  'control-failed', 'unreadable', 'cliff-on-the-date', 'dip-on-the-date']);

const REPAIRS = {
  'grace-access':
    `this organization still reaches an API that shut down on ${SHUTDOWN}. That `
    + 'is grace, not support, and it has no expiry you can read. Move it now: '
    + 'runs become POST /v1/responses carrying a conversation id, threads become '
    + 'POST /v1/conversations, and the OpenAI-Beta header is deleted.',
  'shut-down':
    'runs become POST /v1/responses carrying a conversation id from '
    + 'POST /v1/conversations, and the OpenAI-Beta: assistants=v2 header is '
    + 'deleted. There is no model id to swap here, which is why checking the '
    + 'model id first never helps.',
  'closed-early':
    'the path is already gone and the published date has not arrived. Treat '
    + 'the date as the outside edge rather than the schedule.',
  'control-failed':
    'the control path did not answer either, so nothing was proved about the '
    + 'subject path. Fix the credential or the network and re-run.',
  'cliff-on-the-date':
    "this project's traffic stopped on the shutdown date, so the outage is the "
    + 'closure and not a deploy. Migrate this project first.',
  'dip-on-the-date':
    "part of this project's traffic stopped on the shutdown date. The project "
    + 'serves other work as well, so the assistants share is what needs '
    + 'migrating, not the whole project.',
};

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

/** Whole days from a published date to today. Pure. Negative before it. */
export function daysPast(today, when = SHUTDOWN) {
  return Math.round((day(String(today)) - day(String(when))) / 86400000);
}

/** What one listing's status means on its own. Pure. [state, why]. */
export function probeState(status, body = null) {
  if (status === null || status === undefined) {
    return ['unreachable', 'no response at all from this path'];
  }
  const s = Number(status);
  const b = (body && typeof body === 'object') ? body : {};
  if (s === 200) {
    const kind = b.object || 'a body with no object field';
    return ['answering', `200, and the response is ${kind}`];
  }
  const err = (b.error && typeof b.error === 'object') ? b.error : {};
  const code = err.code || err.type || 'no error code';
  if (s === 404) return ['gone', `404 ${code}, which is what a closed path returns`];
  if (s === 401 || s === 403) {
    return ['credentials', `${s} ${code}, so this probe says nothing about the path`];
  }
  if (s === 429) {
    return ['throttled', `429 ${code}, which is a refusal from a path that still routes`];
  }
  return ['refused', `${s} ${code}`];
}

/** Grade the subject path against the control path. Pure. [state, why]. */
export function accessVerdict(subject, control, past) {
  if (!LIVE.has(control)) {
    return ['control-failed',
      `the control path came back ${control}, so this key proves nothing about `
      + 'the subject path'];
  }
  if (LIVE.has(subject)) {
    if (past >= 0) {
      return ['grace-access',
        `the subject path answered ${past} day(s) after its published shutdown `
        + 'date, which is access on grace rather than a supported state'];
    }
    return ['still-open',
      `the subject path answers and the shutdown is ${-past} day(s) away`];
  }
  if (subject === 'gone') {
    if (past >= 0) {
      return ['shut-down',
        'the control path answers and the subject path does not, so this '
        + `organization is past the ${SHUTDOWN} shutdown`];
    }
    return ['closed-early',
      `the subject path is already gone with ${-past} day(s) still to run on `
      + 'the published date'];
  }
  return ['unreadable',
    `the subject path came back ${subject}, which is neither an answer nor a closure`];
}

/** Grade a daily [[date, requests]] series. Pure. [state, why]. */
export function cliffVerdict(series, when = SHUTDOWN) {
  const rows = (series || [])
    .map(([d, n]) => [String(d), Number(n) || 0])
    .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0));
  if (!rows.length) {
    return ['not-checked', 'no usage buckets were read, so the outage could not be dated'];
  }
  const before = rows.filter(([d]) => d < String(when)).map(([, n]) => n);
  const after = rows.filter(([d]) => d >= String(when)).map(([, n]) => n);
  if (!before.length || !after.length) {
    return ['window-too-short',
      `the window does not span ${when}, so there is nothing to compare across it`];
  }
  const mean = (xs) => xs.reduce((a, b) => a + b, 0) / xs.length;
  const meanBefore = mean(before);
  const meanAfter = mean(after);
  if (meanBefore === 0) {
    return ['no-traffic-in-window',
      `this project had no requests before ${when} either, so there is no `
      + 'outage here to explain'];
  }
  if (meanAfter === 0) {
    const live = rows.filter(([, n]) => n > 0).map(([d]) => d);
    const lastLive = live.length ? live[live.length - 1] : null;
    const eve = new Date(day(String(when)) - 86400000).toISOString().slice(0, 10);
    if (lastLive === eve) {
      return ['cliff-on-the-date',
        `${meanBefore.toFixed(0)} requests/day until ${lastLive} and none from `
        + `${when}, which is the shutdown and not a deploy`];
    }
    return ['cliff-elsewhere',
      `traffic stopped, but the last live day is ${lastLive} rather than ${eve}, `
      + `the day before ${when}`];
  }
  const share = meanAfter / meanBefore;
  if (share <= 0.5) {
    return ['dip-on-the-date',
      `requests fell to ${(share * 100).toFixed(0)}% of the prior mean on ${when}, `
      + 'so part of this project was assistants traffic and part was not'];
  }
  return ['still-running',
    `requests continued across ${when} at ${(share * 100).toFixed(0)}% of the prior mean`];
}

/** The repair for one verdict. Pure. Printed, never performed. */
export function repairLines(state) {
  const line = REPAIRS[state];
  if (!line) return [];
  if (state === 'grace-access' || state === 'shut-down') {
    return [line,
      'the migration guide is Migrate to the Responses API. There is no '
      + 'successor model id, so no config change closes this.'];
  }
  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 usageSeries(key, days) {
  const start = Math.floor(Date.now() / 1000) - days * 86400;
  const [status, body] = await getJson('/organization/usage/completions', key, {
    start_time: start,
    bucket_width: '1d',
    'group_by[]': ['project_id'],
    limit: Math.max(7, Math.min(days, 180)),
  });
  if (status !== 200) {
    console.log(`usage report came back ${status}, so no outage can be dated`);
    return {};
  }
  const out = {};
  for (const bucket of body.data || []) {
    if (!bucket.start_time) continue;
    const d = new Date(bucket.start_time * 1000).toISOString().slice(0, 10);
    for (const row of bucket.results || []) {
      const pid = row.project_id || '(unattributed)';
      (out[pid] ||= []).push([d, row.num_model_requests || 0]);
    }
  }
  return out;
}

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);
  const past = daysPast(today);
  console.log(`shutdown ${SHUTDOWN}, ${Math.abs(past)} day(s) ${past >= 0 ? 'past' : 'away'}`);

  const states = {};
  for (const [role, path] of [['control', CONTROL], ['subject', SUBJECT]]) {
    const [status, body] = await getJson(path, key, { limit: 1 });
    const [state, why] = probeState(status, body);
    states[role] = state;
    console.log(`  ${role.padEnd(8)} GET /v1${path.padEnd(12)} ${status ?? '---'}  ${state.padEnd(12)} ${why}`);
  }

  let findings = 0;
  const [state, why] = accessVerdict(states.subject, states.control, past);
  console.log(`${state.padEnd(20)} ${why}`);
  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-dated'.padEnd(20)} no admin key, so the outage was observed and not dated`);
  } else {
    const series = await usageSeries(admin, days);
    if (!Object.keys(series).length) {
      console.log(`${'not-dated'.padEnd(20)} the usage report returned nothing to date it with`);
    }
    for (const pid of Object.keys(series).sort()) {
      const [cstate, cwhy] = cliffVerdict(series[pid]);
      console.log(`${pid.padEnd(20)} ${cstate.padEnd(18)} ${cwhy}`);
      for (const line of repairLines(cstate)) console.log(`  repair: ${line}`);
      if (FINDINGS.has(cstate)) 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 inversion, and it is the whole note: past the date a 404 is shut-down and a 200 is grace-access, which is the finding. The second is the control, asserted from both sides — a 404 on the subject path with a dead control must never produce a shutdown verdict, because that would be the script reporting a closure it did not observe. Then the 429, which is a refusal from a path that still exists and therefore counts as live. Then the three readings of a usage series: a cliff that lands on the date, the same cliff two days early, which is a deploy and is named as one, and the partial drop that is reported as a dip rather than rounded up. And finally the repair, asserted to describe a rewrite and to contain no model id at all.

test_assistants_shutdown_probe.py
from assistants_shutdown_probe import (SHUTDOWN, access_verdict,
                                       cliff_verdict, days_past, probe_state,
                                       repair_lines)


def series(before=1000.0, after=0.0, last_live="2026-08-25"):
    days = ["2026-08-22", "2026-08-23", "2026-08-24", "2026-08-25",
            "2026-08-26", "2026-08-27", "2026-08-28"]
    out = []
    for d in days:
        if d < SHUTDOWN:
            out.append((d, before if d <= last_live else 0.0))
        else:
            out.append((d, after))
    return out


def test_past_the_date_a_200_is_the_finding_and_a_404_is_the_baseline():
    # The inversion this note exists for. Everywhere else in the section a 404
    # is the alarm; here it is the expected answer.
    state, why = access_verdict("gone", "answering", days_past("2026-08-31"))
    assert state == "shut-down"
    assert SHUTDOWN in why

    state, why = access_verdict("answering", "answering", days_past("2026-08-31"))
    assert state == "grace-access"
    assert "grace rather than a supported state" in why
    assert days_past("2026-08-31") == 5
    assert days_past("2026-08-20") == -6


def test_a_dead_control_path_can_never_produce_a_shutdown_verdict():
    # Without this the script reports a closure it has not observed every time
    # somebody runs it with a revoked key.
    state, why = access_verdict("gone", "credentials", 5)
    assert state == "control-failed"
    assert "proves nothing" in why
    assert access_verdict("gone", "unreachable", 5)[0] == "control-failed"
    assert any("re-run" in line for line in repair_lines("control-failed"))


def test_a_429_is_a_refusal_from_a_path_that_still_exists():
    state, why = probe_state(429, {"error": {"code": "rate_limit_exceeded"}})
    assert state == "throttled"
    assert "still routes" in why
    assert access_verdict("throttled", "answering", 5)[0] == "grace-access"
    assert probe_state(200, {"object": "list"})[0] == "answering"
    assert probe_state(404, {"error": {"code": "model_not_found"}})[0] == "gone"
    assert probe_state(None)[0] == "unreachable"
    assert probe_state(500, {})[0] == "refused"


def test_a_cliff_that_lands_on_the_date_is_the_shutdown():
    state, why = cliff_verdict(series())
    assert state == "cliff-on-the-date"
    assert "not a deploy" in why
    assert any("Migrate this project first" in line
               for line in repair_lines(state))


def test_a_cliff_two_days_early_is_a_deploy_and_is_named_as_one():
    state, why = cliff_verdict(series(last_live="2026-08-23"))
    assert state == "cliff-elsewhere"
    assert "2026-08-23" in why
    assert repair_lines(state) == []


def test_a_partial_drop_is_reported_as_a_dip_and_never_rounded_up():
    # A project that served other work as well loses part of its traffic. That
    # is weaker evidence, so it gets its own state and its own sentence.
    state, why = cliff_verdict(series(after=180.0))
    assert state == "dip-on-the-date"
    assert "18%" in why
    assert "part was not" in why
    assert cliff_verdict(series(after=900.0))[0] == "still-running"
    assert cliff_verdict([])[0] == "not-checked"
    assert cliff_verdict([("2026-08-01", 5)])[0] == "window-too-short"
    assert cliff_verdict(series(before=0.0))[0] == "no-traffic-in-window"


def test_the_repair_describes_a_rewrite_and_names_no_model_id():
    lines = repair_lines("shut-down")
    joined = " ".join(lines)
    assert "/v1/responses" in joined
    assert "/v1/conversations" in joined
    assert "assistants=v2" in joined
    assert "no successor model id" in joined
    assert "gpt-" not in joined
assistants-shutdown-probe.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { SHUTDOWN, accessVerdict, cliffVerdict, daysPast, probeState,
         repairLines } from './assistants-shutdown-probe.mjs';

const series = ({ before = 1000, after = 0, lastLive = '2026-08-25' } = {}) =>
  ['2026-08-22', '2026-08-23', '2026-08-24', '2026-08-25',
   '2026-08-26', '2026-08-27', '2026-08-28'].map((d) =>
    (d < SHUTDOWN ? [d, d <= lastLive ? before : 0] : [d, after]));

test('past the date a 200 is the finding and a 404 is the baseline', () => {
  let [state, why] = accessVerdict('gone', 'answering', daysPast('2026-08-31'));
  assert.equal(state, 'shut-down');
  assert.ok(why.includes(SHUTDOWN));

  [state, why] = accessVerdict('answering', 'answering', daysPast('2026-08-31'));
  assert.equal(state, 'grace-access');
  assert.ok(why.includes('grace rather than a supported state'));
  assert.equal(daysPast('2026-08-31'), 5);
  assert.equal(daysPast('2026-08-20'), -6);
});

test('a dead control path can never produce a shutdown verdict', () => {
  const [state, why] = accessVerdict('gone', 'credentials', 5);
  assert.equal(state, 'control-failed');
  assert.ok(why.includes('proves nothing'));
  assert.equal(accessVerdict('gone', 'unreachable', 5)[0], 'control-failed');
  assert.ok(repairLines('control-failed').some((l) => l.includes('re-run')));
});

test('a 429 is a refusal from a path that still exists', () => {
  const [state, why] = probeState(429, { error: { code: 'rate_limit_exceeded' } });
  assert.equal(state, 'throttled');
  assert.ok(why.includes('still routes'));
  assert.equal(accessVerdict('throttled', 'answering', 5)[0], 'grace-access');
  assert.equal(probeState(200, { object: 'list' })[0], 'answering');
  assert.equal(probeState(404, { error: { code: 'model_not_found' } })[0], 'gone');
  assert.equal(probeState(null)[0], 'unreachable');
  assert.equal(probeState(500, {})[0], 'refused');
});

test('a cliff that lands on the date is the shutdown', () => {
  const [state, why] = cliffVerdict(series());
  assert.equal(state, 'cliff-on-the-date');
  assert.ok(why.includes('not a deploy'));
  assert.ok(repairLines(state).some((l) => l.includes('Migrate this project first')));
});

test('a cliff two days early is a deploy and is named as one', () => {
  const [state, why] = cliffVerdict(series({ lastLive: '2026-08-23' }));
  assert.equal(state, 'cliff-elsewhere');
  assert.ok(why.includes('2026-08-23'));
  assert.deepEqual(repairLines(state), []);
});

test('a partial drop is reported as a dip and never rounded up', () => {
  const [state, why] = cliffVerdict(series({ after: 180 }));
  assert.equal(state, 'dip-on-the-date');
  assert.ok(why.includes('18%'));
  assert.ok(why.includes('part was not'));
  assert.equal(cliffVerdict(series({ after: 900 }))[0], 'still-running');
  assert.equal(cliffVerdict([])[0], 'not-checked');
  assert.equal(cliffVerdict([['2026-08-01', 5]])[0], 'window-too-short');
  assert.equal(cliffVerdict(series({ before: 0 }))[0], 'no-traffic-in-window');
});

test('the repair describes a rewrite and names no model id', () => {
  const joined = repairLines('shut-down').join(' ');
  assert.ok(joined.includes('/v1/responses'));
  assert.ok(joined.includes('/v1/conversations'));
  assert.ok(joined.includes('assistants=v2'));
  assert.ok(joined.includes('no successor model id'));
  assert.ok(!joined.includes('gpt-'));
});

FAQ

The Assistants API is already gone, so why run a script about it?

Because it is not gone everywhere at the same moment. Access after a published shutdown date depends on what the provider is still routing and for whom, and the case this note exists for is the organization that still gets a 200 five days later. Nothing there is broken, so nobody is looking, and the access has no expiry you can read. The script exists to find the one that has not failed yet, not the one that already told you.

Why probe /v1/models as well? I know my key works.

Because the script does not, and a 404 from a closed path and a 404 from a key with no access are the same number. Holding the credential, the headers and the host fixed while varying only the path is what turns one status code into evidence. If the control path does not answer, the script refuses to report a shutdown at all rather than reporting one it did not observe, and that refusal is a tested behaviour.

How is this different from the note about a model past its shutdown date?

That note reads shutdown_date off the model object and diffs the model strings in your config against the model list. It cannot see this at all: no model was retired here, every id involved still resolves, and the thing that disappeared is a path, which has no entry in GET /v1/models to check. The distinction is the whole reason batch U exists. A retired model id is a one-line diff; a closed endpoint family is a rewrite.

The usage report shows a drop but not to zero. Is that the shutdown?

Partly, and the script says partly. A project that ran assistants alongside ordinary completions loses the assistants share on the date and keeps the rest, which shows up as a step down rather than a cliff. That comes back as dip-on-the-date with the percentage attached, and it is deliberately a weaker claim than a cliff. What it tells you is that this project has assistants traffic to migrate; what it cannot tell you is how much, because the report does not separate the two.

What actually has to change in the code?

The call graph, not a constant. An assistant plus a thread plus a run becomes a single POST /v1/responses carrying a conversation id from POST /v1/conversations, tool definitions move onto the response request, and the OpenAI-Beta: assistants=v2 header is deleted rather than updated. The script prints that shape and the projects that still have traffic on it. It deliberately prints no model id, because substituting one is what people try first and it never helps.

Related field notes

Sources

Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.