Skip to content

Diagnostic LLM APIs

a retired model id still sitting in the code

A batch job that runs on the first of the month failed on every request with 404 and "type": "not_found_error", message The requested resource could not be found. The endpoint is right, the key works, the same key runs the rest of the application all day. The model id in that job's params block was retired months ago, and nothing else in the codebase names it, so nothing else broke and nobody knew.

Read-only key Python and Node.js Tests included
Server cabinets
Photo by Eric Stoynov on Unsplash
The short answer

Collect every model string in your tree — configs, defaults, fallback branches, batch bodies, fixtures — and diff them against GET /v1/models?limit=1000. Anything not in data[].id is not callable. Confirm one at a time with GET /v1/models/{id}: a live id returns a model object, a retired one returns 404 not_found_error.

There is no date to read here. Anthropic's model object carries no retirement field, so the only thing the API can tell you is presence or absence, and the date has to come from the published deprecation table.

The problem in plain words

This is the failure that survives a migration. Somebody moves the main call path to the new model, tests it, ships it, and closes the ticket — and the old string stays alive in the three places nobody greps: the model default in a helper function's signature, the cheaper fallback used when the primary times out, and the params block of a batch that runs monthly. Each of them is exercised rarely enough to look fine for a quarter.

Then it fails on the worst possible schedule. The fallback breaks precisely when the primary is already struggling, so a partial degradation becomes a total one. The batch breaks on its next run, days after the retirement, with the error in a log nobody tails. And the 404 body says nothing about retirement: the requested resource could not be found is the same sentence you would get for a mistyped id.

Main pathmigratedtested, shipped,closedString left ina fallbackand a monthlybatchId retireddropped from thelistRare path runsa bad day, or the1st404not_found_errorin a log nobodytails
The main call path moved months ago. The string that did not move is in the branch that only runs when something is already wrong.

Why it happens

Retirement removes the id, it does not mark it. The models list is a list of what is callable now. A retired id is simply absent from it, and GET /v1/models/{id} returns the generic 404. There is no tombstone entry, no status: "retired", no date field on the model object — so unlike the OpenAI side, you cannot read the deadline from the API either before or after it passes.

Absence is only detectable if you know what to look for. A diff needs both sides, and the API only gives you one. The other side is your own source tree, which is why this check takes the model strings as input rather than discovering them: nothing in the API knows what your config file says.

Usage confirms the id is dead, never that it is alive. The Admin usage report grouped by model shows an id's traffic stopping dead on its retirement date, because the calls started failing, and a retired id never reappears. That makes it good confirming evidence and useless as a warning: by the time the shape is visible, the outage has already happened.

Alive elsewhere is not alive here. Bedrock and Vertex run their own retirement schedules, generally later than the first-party API. An id that a colleague insists is still working may well be working, on a platform this key does not talk to. That is why an id which is neither in the live list nor on the deprecation table is reported as unknown rather than as retired.

The fix, as a flow

There is no date to read on this API, so the whole detection is a set difference: your own model strings on one side, the live models list on the other, and the finding is whatever is only on your side.

Config strings diffedagainst GET /v1/modelsIn the live listcallable by this workspaceMissing, on the tableretired, with a replacementMissing, not on ittypo, or another platformListed but table says deadthe table is stale, not the API
Missing from the live list is not the same as retired. Bedrock and Vertex retire later, so an unplaceable id is reported as unknown.

How to fix it

Collect the model strings, all of them

Grep for the vendor prefix across the whole repository and the infrastructure that configures it: grep -rn "claude-" --include='*.py' --include='*.ts' --include='*.yaml' ., plus environment variables and secret stores. Default arguments and fallback branches are the two that get missed, and they are the two that fail worst.

List what is actually callable

GET https://api.anthropic.com/v1/models?limit=1000 with x-api-key and anthropic-version: 2023-06-01, following has_more and last_id. This is the authority on what exists for this workspace right now.

Diff, then confirm one by one

Anything in your strings but not in data[].id is the finding. Confirm each with GET /v1/models/{id} so a paging mistake does not turn into a false alarm; a retired id returns 404 with not_found_error, and the SDKs raise the typed 404 class rather than a generic error.

Separate retired from never-existed

Join the missing ids against the deprecation table. One that matches is retired and has a documented replacement. One that matches nothing is a typo, a partner-platform id, or a model this workspace has not been granted — three different repairs, and calling all of them "retired" sends people to the wrong page.

Replace the string everywhere it appears, then re-run

The replacement lines are documented: the Opus 4 and 4.1 line rolls forward to claude-opus-4-8, the Sonnet line to claude-sonnet-4-6, the Haiku and Instant line to claude-haiku-4-5-20251001. Re-run the script with the same input list afterwards; a clean run is the only proof that the fallback branch got the change too.

How to check it worked

Feed the script the same list of strings after the change. Every one should come back as live.

python3 anthropic_model_ids_audit.py --from-file models-in-use.txt
# 6 id(s) checked against 14 live model(s), 0 retired, 0 unknown

The full code

GET requests only, with a workspace key. The classifier is pure and takes the live id set and the date as arguments, so the whole thing is testable without a network: what is callable comes from the API, what is retired comes from a table copied off the deprecations page, and the interesting conflict — the table saying retired while the API still lists the id — gets its own state rather than being resolved in favour of whichever check ran first.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 24 LLM API fixes, free and open source.
anthropic_model_ids_audit.py
"""Find retired Claude model ids still named in your configuration.

Read only. GET requests and nothing else: give this a workspace API key. The
repair is printed, never performed, because this script holds a credential that
can spend real money on 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("anthropic_model_ids_audit")

API = "https://api.anthropic.com/v1"
VERSION = "2023-06-01"

# Copied from the published deprecations page, because the API has no retirement
# field at all: the model object carries created_at and the token limits and
# nothing about the end of life. A hardcoded table goes stale, so the live list
# from the API always wins over this one; see verdict().
RETIRED = {
    "claude-opus-4-1-20250805": "2026-08-05",
    "claude-opus-4-20250514": "2026-06-15",
    "claude-sonnet-4-20250514": "2026-06-15",
    "claude-3-haiku-20240307": "2026-04-20",
    "claude-3-7-sonnet-20250219": "2026-02-19",
    "claude-3-5-haiku-20241022": "2026-02-19",
    "claude-3-opus-20240229": "2026-01-05",
    "claude-3-5-sonnet-20240620": "2025-10-28",
    "claude-3-5-sonnet-20241022": "2025-10-28",
    "claude-3-sonnet-20240229": "2025-07-21",
    "claude-2.0": "2025-07-21",
    "claude-2.1": "2025-07-21",
    "claude-1.0": "2024-11-06",
    "claude-1.1": "2024-11-06",
    "claude-1.2": "2024-11-06",
    "claude-1.3": "2024-11-06",
    "claude-instant-1.0": "2024-11-06",
    "claude-instant-1.1": "2024-11-06",
    "claude-instant-1.2": "2024-11-06",
}

BAD = ("retired", "unknown", "table-stale", "unreadable")


def replacement(model_id):
    """Where a retired line rolls forward to, by family.

    Family level on purpose. This says the Opus line continues as Opus, not that
    any two snapshots behave the same: a model swap still needs evaluating.
    """
    if "opus" in model_id:
        return "claude-opus-4-8"
    if "haiku" in model_id or "instant" in model_id:
        return "claude-haiku-4-5-20251001"
    if "sonnet" in model_id or model_id.startswith(("claude-1", "claude-2")):
        return "claude-sonnet-4-6"
    return None


def days_since(day_str, today):
    """Whole days from a YYYY-MM-DD string to `today`, or None if unreadable."""
    try:
        return (today - dt.date.fromisoformat(str(day_str))).days
    except (TypeError, ValueError):
        return None


def verdict(model_id, live_ids, today):
    """Classify one model string against the live list and the retirement table.

    Pure: both the live set and the date come in as arguments, so this is
    testable with no network and no clock. Returns (state, detail).

    The live list wins over the table. If the API still lists an id the table
    calls retired, the table is out of date, not the API, and saying so is more
    useful than reporting an outage that is not happening.
    """
    model_id = str(model_id or "").strip()
    if not model_id:
        return ("unreadable", "empty model string")

    retired_on = RETIRED.get(model_id)

    if model_id in live_ids:
        if retired_on:
            return ("table-stale",
                    "still in the live models list, though the local table says "
                    "it retired on %s. Trust the API and correct the table."
                    % (retired_on,))
        return ("live", "in the live models list for this workspace")

    if retired_on:
        ago = days_since(retired_on, today)
        when = ("%s, %d day(s) ago" % (retired_on, ago) if ago is not None
                else retired_on)
        moved_to = replacement(model_id)
        return ("retired",
                "retired on %s. Every request naming it returns 404 "
                "not_found_error, the same body a mistyped id returns.%s"
                % (when, " Line continues as %s." % moved_to if moved_to else ""))

    return ("unknown",
            "not in the live list and not on the deprecation table. That is a "
            "typo, an id that only exists on Bedrock or Vertex (which run later "
            "retirement schedules), or a model this workspace has not been "
            "granted. Three different repairs, so check before assuming.")


def get(session, path, **params):
    r = session.get(API + path, params=params, timeout=30)
    if r.status_code in (401, 403):
        raise SystemExit("%d from Anthropic: check ANTHROPIC_API_KEY; an Admin "
                         "key cannot read the models list" % r.status_code)
    r.raise_for_status()
    return r.json()


def live_model_ids(session):
    """Every id callable by this workspace key, following the cursor."""
    ids, params = set(), {"limit": 1000}
    while True:
        page = get(session, "/models", **params)
        data = page.get("data", [])
        ids.update(str(m.get("id")) for m in data if m.get("id"))
        if not page.get("has_more") or not page.get("last_id"):
            break
        params["after_id"] = page["last_id"]
    return ids


def read_ids(args):
    """Model strings from the command line and, optionally, a file of them."""
    ids = list(args.model)
    if args.from_file:
        with open(args.from_file, "r", encoding="utf-8") as fh:
            for line in fh:
                line = line.split("#", 1)[0].strip()
                if line:
                    ids.append(line)
    seen, unique = set(), []
    for model_id in ids:
        if model_id not in seen:
            seen.add(model_id)
            unique.append(model_id)
    return unique


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--model", action="append", default=[],
                    help="a model string found in your code; repeatable")
    ap.add_argument("--from-file",
                    help="file of model strings, one per line, # for comments")
    args = ap.parse_args()

    wanted = read_ids(args)
    if not wanted:
        log.error("give at least one --model, or a --from-file list. Collect "
                  "them with: grep -rn 'claude-' .")
        return 2

    key = os.environ.get("ANTHROPIC_API_KEY")
    if not key:
        log.error("set ANTHROPIC_API_KEY (a workspace key; this script only "
                  "sends GET requests)")
        return 2

    session = requests.Session()
    session.headers.update({"x-api-key": key, "anthropic-version": VERSION})

    live = live_model_ids(session)
    today = dt.date.today()

    counts, bad = {}, 0
    for model_id in wanted:
        state, detail = verdict(model_id, live, today)
        counts[state] = counts.get(state, 0) + 1
        line = "%-12s %s  %s" % (state, model_id or "<empty>", detail)
        if state not in BAD:
            log.info(line)
            continue
        bad += 1
        log.warning(line)
        if state == "retired":
            moved_to = replacement(model_id)
            log.warning("  repair: replace the string %r with %r everywhere it "
                        "appears, including default arguments, fallback "
                        "branches and batch request bodies",
                        model_id, moved_to or "the documented replacement")

    log.info("%d id(s) checked against %d live model(s), %d retired, %d unknown",
             len(wanted), len(live), counts.get("retired", 0),
             counts.get("unknown", 0))
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main())
anthropic-model-ids-audit.mjs
/**
 * Find retired Claude model ids still named in your configuration.
 *
 * Read only. GET requests and nothing else: give this a workspace API key. The
 * repair is printed, never performed.
 */
import { readFileSync } from 'node:fs';

const API = 'https://api.anthropic.com/v1';
const VERSION = '2023-06-01';

// Copied from the published deprecations page, because the API has no
// retirement field at all. A hardcoded table goes stale, so the live list from
// the API always wins over this one; see verdict().
export const RETIRED = {
  'claude-opus-4-1-20250805': '2026-08-05',
  'claude-opus-4-20250514': '2026-06-15',
  'claude-sonnet-4-20250514': '2026-06-15',
  'claude-3-haiku-20240307': '2026-04-20',
  'claude-3-7-sonnet-20250219': '2026-02-19',
  'claude-3-5-haiku-20241022': '2026-02-19',
  'claude-3-opus-20240229': '2026-01-05',
  'claude-3-5-sonnet-20240620': '2025-10-28',
  'claude-3-5-sonnet-20241022': '2025-10-28',
  'claude-3-sonnet-20240229': '2025-07-21',
  'claude-2.0': '2025-07-21',
  'claude-2.1': '2025-07-21',
  'claude-1.0': '2024-11-06',
  'claude-1.1': '2024-11-06',
  'claude-1.2': '2024-11-06',
  'claude-1.3': '2024-11-06',
  'claude-instant-1.0': '2024-11-06',
  'claude-instant-1.1': '2024-11-06',
  'claude-instant-1.2': '2024-11-06',
};

const BAD = ['retired', 'unknown', 'table-stale', 'unreadable'];
const DAY = 86400000;

/**
 * Where a retired line rolls forward to, by family. Family level on purpose:
 * this says the Opus line continues as Opus, not that any two snapshots behave
 * the same.
 */
export function replacement(modelId) {
  if (modelId.includes('opus')) return 'claude-opus-4-8';
  if (modelId.includes('haiku') || modelId.includes('instant')) {
    return 'claude-haiku-4-5-20251001';
  }
  if (modelId.includes('sonnet') || /^claude-[12]/.test(modelId)) {
    return 'claude-sonnet-4-6';
  }
  return null;
}

/** Whole days from a YYYY-MM-DD string to `today`, or null if unreadable. */
export function daysSince(dayStr, today) {
  if (!/^\d{4}-\d{2}-\d{2}$/.test(String(dayStr))) return null;
  const ms = Date.parse(`${dayStr}T00:00:00Z`);
  if (Number.isNaN(ms)) return null;
  return Math.round((today.getTime() - ms) / DAY);
}

/**
 * Classify one model string against the live list and the retirement table.
 * Pure: both the live set and the date come in as arguments. Returns
 * [state, detail].
 *
 * The live list wins over the table. If the API still lists an id the table
 * calls retired, the table is out of date, not the API.
 */
export function verdict(modelId, liveIds, today) {
  const id = String(modelId ?? '').trim();
  if (!id) return ['unreadable', 'empty model string'];

  const retiredOn = RETIRED[id];

  if (liveIds.has(id)) {
    if (retiredOn) {
      return ['table-stale',
        `still in the live models list, though the local table says it retired ` +
        `on ${retiredOn}. Trust the API and correct the table.`];
    }
    return ['live', 'in the live models list for this workspace'];
  }

  if (retiredOn) {
    const ago = daysSince(retiredOn, today);
    const when = ago === null ? retiredOn : `${retiredOn}, ${ago} day(s) ago`;
    const movedTo = replacement(id);
    return ['retired',
      `retired on ${when}. Every request naming it returns 404 not_found_error, ` +
      `the same body a mistyped id returns.` +
      (movedTo ? ` Line continues as ${movedTo}.` : '')];
  }

  return ['unknown',
    'not in the live list and not on the deprecation table. That is a typo, an ' +
    'id that only exists on Bedrock or Vertex (which run later retirement ' +
    'schedules), or a model this workspace has not been granted. Three ' +
    'different repairs, so check before assuming.'];
}

async function get(key, path, params = {}) {
  const url = new URL(API + path);
  for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
  const res = await fetch(url, {
    headers: { 'x-api-key': key, 'anthropic-version': VERSION },
  });
  if (res.status === 401 || res.status === 403) {
    throw new Error(`${res.status} from Anthropic: check ANTHROPIC_API_KEY; an ` +
                    'Admin key cannot read the models list');
  }
  if (!res.ok) throw new Error(`${res.status} from ${path}`);
  return res.json();
}

export async function liveModelIds(key) {
  const ids = new Set();
  const params = { limit: 1000 };
  for (;;) {
    const page = await get(key, '/models', params);
    for (const m of page.data ?? []) if (m.id) ids.add(String(m.id));
    if (!page.has_more || !page.last_id) break;
    params.after_id = page.last_id;
  }
  return ids;
}

function readIds(argv) {
  const ids = [];
  argv.forEach((arg, i) => {
    if (arg === '--model' && argv[i + 1]) ids.push(argv[i + 1]);
    if (arg === '--from-file' && argv[i + 1]) {
      for (const line of readFileSync(argv[i + 1], 'utf8').split('\n')) {
        const trimmed = line.split('#')[0].trim();
        if (trimmed) ids.push(trimmed);
      }
    }
  });
  return [...new Set(ids)];
}

async function main() {
  const wanted = readIds(process.argv);
  if (wanted.length === 0) {
    console.error("give at least one --model, or a --from-file list. Collect " +
                  "them with: grep -rn 'claude-' .");
    process.exitCode = 2;
    return;
  }

  const key = process.env.ANTHROPIC_API_KEY;
  if (!key) {
    console.error('set ANTHROPIC_API_KEY (a workspace key; this script only ' +
                  'sends GET requests)');
    process.exitCode = 2;
    return;
  }

  const live = await liveModelIds(key);
  const today = new Date(`${new Date().toISOString().slice(0, 10)}T00:00:00Z`);

  const counts = new Map();
  let bad = 0;
  for (const modelId of wanted) {
    const [state, detail] = verdict(modelId, live, today);
    counts.set(state, (counts.get(state) ?? 0) + 1);
    const line = `${state.padEnd(12)} ${modelId || '<empty>'}  ${detail}`;
    if (!BAD.includes(state)) { console.log(line); continue; }
    bad += 1;
    console.warn(line);
    if (state === 'retired') {
      const movedTo = replacement(modelId) ?? 'the documented replacement';
      console.warn(`  repair: replace the string "${modelId}" with "${movedTo}" ` +
        'everywhere it appears, including default arguments, fallback branches ' +
        'and batch request bodies');
    }
  }

  console.log(`${wanted.length} id(s) checked against ${live.size} live ` +
    `model(s), ${counts.get('retired') ?? 0} retired, ` +
    `${counts.get('unknown') ?? 0} unknown`);
  process.exitCode = bad ? 1 : 0;
}

// Only run when invoked directly. The test file imports this module, and without
// the guard main() would run there too, fail on the missing key, and set a
// non-zero exit code that fails the whole test file even as every test passes.
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

The live set and the date are both handed in, so every case runs offline. The two worth pinning are the ones that keep the script honest about what it does not know: an id missing from the live list but absent from the deprecation table is unknown rather than retired, and an id the table calls retired while the API still lists it means the table is stale — not that a working model should be reported as an outage.

test_anthropic_model_ids_audit.py
import datetime as dt

from anthropic_model_ids_audit import days_since, replacement, verdict

TODAY = dt.date(2026, 8, 30)
LIVE = {"claude-opus-4-8", "claude-sonnet-4-6", "claude-haiku-4-5-20251001",
        "claude-opus-4-1-20250805"}


def test_an_id_in_the_live_list_is_callable():
    state, detail = verdict("claude-sonnet-4-6", LIVE, TODAY)
    assert state == "live"
    assert "live models list" in detail


def test_an_id_missing_from_the_list_and_on_the_table_is_retired():
    state, detail = verdict("claude-3-5-sonnet-20241022", LIVE - {"x"}, TODAY)
    assert state == "retired"
    assert "2025-10-28" in detail
    assert "not_found_error" in detail
    assert "claude-sonnet-4-6" in detail


def test_the_days_since_retirement_are_counted_from_the_date_passed_in():
    assert days_since("2026-06-15", TODAY) == 76
    assert days_since("not a date", TODAY) is None
    assert "76 day(s) ago" in verdict("claude-opus-4-20250514", set(), TODAY)[1]


def test_missing_from_the_list_but_not_on_the_table_is_unknown():
    state, detail = verdict("claude-sonnet-4-6-20260101", set(), TODAY)
    assert state == "unknown"
    assert "Bedrock" in detail


def test_the_api_wins_over_the_hardcoded_table():
    # The table is a copy of a web page and this one has gone stale. Reporting
    # an outage on a model the API is still serving would be worse than useless.
    state, detail = verdict("claude-opus-4-1-20250805", LIVE, TODAY)
    assert state == "table-stale"
    assert "Trust the API" in detail


def test_an_empty_string_is_not_silently_live():
    assert verdict("", LIVE, TODAY)[0] == "unreadable"
    assert verdict(None, LIVE, TODAY)[0] == "unreadable"


def test_the_replacement_is_family_level_and_admits_ignorance():
    assert replacement("claude-3-opus-20240229") == "claude-opus-4-8"
    assert replacement("claude-3-5-haiku-20241022") == "claude-haiku-4-5-20251001"
    assert replacement("claude-instant-1.2") == "claude-haiku-4-5-20251001"
    assert replacement("claude-2.1") == "claude-sonnet-4-6"
    assert replacement("some-other-vendor-model") is None
anthropic-model-ids-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { daysSince, replacement, verdict } from './anthropic-model-ids-audit.mjs';

const TODAY = new Date('2026-08-30T00:00:00Z');
const LIVE = new Set(['claude-opus-4-8', 'claude-sonnet-4-6',
                      'claude-haiku-4-5-20251001', 'claude-opus-4-1-20250805']);

test('an id in the live list is callable', () => {
  const [state, detail] = verdict('claude-sonnet-4-6', LIVE, TODAY);
  assert.equal(state, 'live');
  assert.match(detail, /live models list/);
});

test('an id missing from the list and on the table is retired', () => {
  const [state, detail] = verdict('claude-3-5-sonnet-20241022', new Set(), TODAY);
  assert.equal(state, 'retired');
  assert.match(detail, /2025-10-28/);
  assert.match(detail, /not_found_error/);
  assert.match(detail, /claude-sonnet-4-6/);
});

test('the days since retirement are counted from the date passed in', () => {
  assert.equal(daysSince('2026-06-15', TODAY), 76);
  assert.equal(daysSince('not a date', TODAY), null);
  assert.match(verdict('claude-opus-4-20250514', new Set(), TODAY)[1],
               /76 day\(s\) ago/);
});

test('missing from the list but not on the table is unknown', () => {
  const [state, detail] = verdict('claude-sonnet-4-6-20260101', new Set(), TODAY);
  assert.equal(state, 'unknown');
  assert.match(detail, /Bedrock/);
});

test('the api wins over the hardcoded table', () => {
  const [state, detail] = verdict('claude-opus-4-1-20250805', LIVE, TODAY);
  assert.equal(state, 'table-stale');
  assert.match(detail, /Trust the API/);
});

test('an empty string is not silently live', () => {
  assert.equal(verdict('', LIVE, TODAY)[0], 'unreadable');
  assert.equal(verdict(null, LIVE, TODAY)[0], 'unreadable');
});

test('the replacement is family level and admits ignorance', () => {
  assert.equal(replacement('claude-3-opus-20240229'), 'claude-opus-4-8');
  assert.equal(replacement('claude-3-5-haiku-20241022'), 'claude-haiku-4-5-20251001');
  assert.equal(replacement('claude-instant-1.2'), 'claude-haiku-4-5-20251001');
  assert.equal(replacement('claude-2.1'), 'claude-sonnet-4-6');
  assert.equal(replacement('some-other-vendor-model'), null);
});

FAQ

Why does the 404 not say the model was retired?

Because not_found_error means the resource is not addressable, and the API no longer holds anything at that id to describe. A retired model, a mistyped model and a model your workspace was never granted all produce the same body. The models list is what distinguishes them, and only while you still know which strings your code uses.

Can I get the retirement date out of the API?

No. The Claude model object returns id, display_name, created_at and the token limits. There is no retirement field before the date and no tombstone after it, so the date comes from the published deprecations page. The API tells you callable or not, which is the whole of its contribution here.

The usage report shows the id, so is it still working?

Check when it last appeared. A retired id stops accruing usage on its retirement date because the calls fail, and it never comes back. Traffic that stops dead on a published date is the fingerprint of this problem, not evidence against it.

A teammate says the id still works for them. Who is right?

Possibly both of you. Amazon Bedrock and Google Cloud set their own retirement dates, generally later than the first-party API, so a model can be dead on api.anthropic.com and alive on Bedrock. The script reports an id it cannot place as unknown rather than retired for exactly this reason.

Is running this in CI safe with a key that can send messages?

A workspace key is all-or-nothing on the data plane: the same credential that reads GET /v1/models could send a message. This script only ever issues GET requests, which is a property you can verify by reading it, but the safer control is to give CI a key scoped to a workspace with no budget rather than one that fronts production.

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.