Diagnostic LLM APIs
The anthropic-beta value that 400s, and the one that went GA
The pull request adds one header and it is nine characters different from the one in the documentation. Every request the service makes now returns 400, the message names the header, and it takes about four minutes to spot. That is the easy half. The hard half is the header three services along that is spelled perfectly, was correct when it was written, returns 200 today, and is quietly holding that client on a response shape the platform stopped documenting in August — no error, no warning, and a list endpoint that has been missing expires_at for months.
Use the model listing as a validator. GET /v1/models accepts and validates an anthropic-beta header, so with a workspace key it is a free, zero-token check for any beta string: 400 means the value is rejected, 200 means it is accepted. Loop it over every string your code sends and you have graded the whole set for the price of nothing.
A 400 does not tell you which of two problems you have. The documented message — Unexpected value(s) invalid-beta-name for the anthropic-beta header — is returned both for a name that does not exist and for a beta your organization is not entitled to. The script says so in one line rather than picking, and near-matches the rejected string against the beta enum the Models API reference publishes, so a typo comes back with the name you meant.
Then the second pass, which is the half nobody runs. A beta that graduated still returns 200 and is not inert: it pins you to the response shape it shipped with. So the script issues the same GET twice, once with the header and once without, and diffs the two JSON bodies by key. The Files API is the documented worked example: with files-api-2025-04-14 a listing returns has_more, first_id and last_id and no expires_at; without it, next_page and an expires_at on every file.
Two structural faults come out of the same input without any request at all. Multiple betas go in one comma-separated header, so a trailing comma, an internal space or a duplicated name is a bug in the string itself. And endpoint-scoped betas are not freely combinable: on memory-store endpoints agent-memory-2026-07-22 replaces managed-agents-2026-04-01, and sending both returns a 400.
What a 200 does not prove is worth stating plainly, because the note about the 1M context window refuses to make this call for exactly this reason. Acceptance at /v1/models means the name is recognised. It says nothing about whether the beta still does anything on /v1/messages. This script claims validity and shape, and nothing else.
The problem in plain words
Beta names follow feature-name-YYYY-MM-DD and must match exactly. They are long, they are dated, they are copied by hand out of documentation and Slack messages, and there is no autocomplete for a string in a header. So they get misspelled, they get pinned to the wrong date, and they get copied from a page that has since been rewritten. The failure is total and immediate: the whole call returns 400 before anything else about the request is considered.
The entitlement case wears the same clothes. A beta your organization does not have access to returns the same message as a beta that does not exist, which means the obvious debugging move — checking the spelling — can be correct and useless at the same time. Somebody spends an hour proving a string matches the docs character for character while the actual answer is that the feature was never enabled for that organization.
The quiet failure is the graduated header, and it is the reason this note exists rather than being a paragraph in an error-message reference. Betas graduate. When they do, the header becomes optional but not inert: requests that still send it keep receiving the response shape from the beta era. The Files API graduation is documented as a table of exactly these differences. A client sitting on the old header gets has_more/first_id/last_id pagination instead of next_page, cannot use the ids[] filter, and never sees expires_at at all — which means expiry handling it does not know it needs.
And the header is a single string carrying a list, which is its own small hazard. Multiple betas are comma-separated in one header. A trailing comma produces an empty segment. A space after the comma may or may not survive. A duplicate is silently pointless. None of these is a spelling mistake and none of them is visible by reading the name.
Why it happens
The endpoint that validates the header for free is not the endpoint the header is for, and that is fine here. Beta names are validated at the request layer, so the models listing rejects an unknown one exactly as the messages endpoint would, at a cost of zero tokens and zero dollars. That makes a loop over every string in your tree cheap enough to run on every deploy, which is the only frequency at which this kind of check is worth anything.
A 400 is two findings sharing a message, and collapsing them would be a lie. Invalid name and missing entitlement return the same body. The script reports both possibilities on one line and adds the near-match if there is one, because a suggested spelling resolves the ambiguity for free in the common case and an invented certainty resolves nothing in the other one.
The published enum is a hint corpus, never the verdict. The Models API reference lists the beta names the endpoint accepts, which makes it an excellent source of near-matches. It is also a document, and documents lag. So a string the API accepts and the enum does not list is reported as the enum is behind, not as an error — the probe is the authority and the list is the dictionary.
A 200 with no shape difference is unproven, not proven fine. Only some betas change a readable GET, and the two endpoints this script can safely read are the models listing and the files listing. When the two bodies come back identical the honest output is that no difference was observable here, which is a statement about the test and not about the header. Reporting that as healthy would be the same mistake as treating acceptance as effect.
The structural faults need no network at all, and are graded first. An empty segment from a trailing comma, an internal space, a duplicate, and the documented conflicting pair on memory-store endpoints are all properties of the string you declared. Probing them wastes a request and, worse, buries a definite finding underneath a set of status codes.
This is a note about a header, not about a model. A configured model id that no longer exists belongs to the retired-id note, and a context window capped in software belongs to the 1M note. Both of those read the model object. Nothing here does: every finding in this script is a property of a string in a header, which is why the same script covers betas for features this organization may not even use.
The fix, as a flow
The models endpoint accepts and validates the beta header, so it is a free zero token validator for any string your code sends. That gets you the 400s. It does not get you the quieter half, which is a name that is still perfectly valid and no longer needed, so the script makes a second pass: the same GET twice, once with the header and once without, with the two JSON bodies diffed by key.
How to fix it
Collect the beta strings per call site, not as a set
ANTHROPIC_BETA_HEADERS as JSON mapping a call site to the raw header value it sends, or repeated --beta for a flat list. Per call site matters: the conflicting-pair check and the malformed-string checks are properties of one header value, and a flattened set loses both.
Grade the strings before making a single request
Split each value on commas. Empty segments, internal whitespace, duplicates and uppercase are faults in the string itself. Then check for the documented conflicting pair: on memory-store endpoints agent-memory-2026-07-22 replaces managed-agents-2026-04-01, and both together is a 400.
Probe every distinct name against the model listing
GET /v1/models?limit=1 with anthropic-version: 2023-06-01 and anthropic-beta: <value>. 400 is rejected, 200 is accepted. Do not raise on the 400 — it is the answer. Near-match every rejected name against the published enum and print the candidates.
Diff the response shape with and without each accepted name
The same GET twice, on /v1/models and on /v1/files. Compare the top-level keys and the keys of the first item in data. A difference proves the header still changes what you receive, which for a graduated beta is exactly the finding.
Print the repair, and print what was not proved
Per rejected name: the near-matches and the entitlement possibility. Per shape difference: the keys you are missing and the migration to read. And for every accepted name with no observable difference, the sentence that this endpoint pair could not tell — not that the header is fine.
How to check it worked
Fix a spelling and re-run: that name should move from rejected-typo to accepted. Drop a graduated header and re-run: the shape diff for it disappears, because there is no longer a header to diff against. The result that should not change is no-visible-difference, which is the script declining to grade rather than the script approving.
ANTHROPIC_BETA_HEADERS='{"src/messages.py":"context-management-2025-06-27,contxt-1m-2025-08-07",
"src/files.py":"files-api-2025-04-14",
"src/memory.py":"agent-memory-2026-07-22,managed-agents-2026-04-01"}' \
python3 anthropic_beta_header_audit.py
# 4 distinct beta string(s) across 3 call site(s)
# rejected-typo contxt-1m-2025-08-07: 400. Invalid, or a beta this
# organization is not entitled to; the API returns the same
# message for both
# closest documented names: context-1m-2025-08-07, context-management-2025-06-27
# repair: replace it with context-1m-2025-08-07, then re-run this probe.
# pinned-to-beta-shape files-api-2025-04-14: accepted, and the response differs
# with and without it
# /v1/files top-level keys only with the header: first_id, has_more, last_id
# /v1/files top-level keys only without it: next_page
# /v1/files item keys only without it: expires_at
# repair: the beta graduated. Removing the header switches you to page and
# next_page cursors, the ids[] filter, and an expires_at on every
# file. Read the migration table before dropping it.
# conflicting-pair src/memory.py sends agent-memory-2026-07-22 with
# managed-agents-2026-04-01
# repair: on memory store endpoints the first replaces the second. Sending
# both returns 400. Send agent-memory-2026-07-22 alone there.
# accepted context-management-2025-06-27: 200, documented
# no-visible-difference context-management-2025-06-27: same keys with and
# without it on the endpoints this script can read, which
# is not evidence that the header does nothing
# 3 finding(s)
The full code
Two GETs per accepted name per endpoint, and eight pure functions. split_betas, which turns one header value into names plus the structural faults in the string; load_call_sites, which accepts JSON, a list or a bare comma string so the input can come from wherever your config keeps it; levenshtein and near_matches, which rank a rejected name against the published enum; classify_probe, which reports the two meanings of a 400 without choosing between them; key_sets and shape_delta, which compare two bodies by key at the top level and at the first item; graduation_verdict, which is careful to say that an identical pair proves nothing; and conflicting, for the one documented pair that must not be sent together.
"""Grade every anthropic-beta string your code sends, without sending one.
Read only. Every request is a GET: /v1/models to validate a beta name, and
/v1/models plus /v1/files twice each to compare a response with and without a
header. No request body is constructed and nothing is generated or billed.
Two passes, because the two failures do not share a signal. A misspelled or
unentitled name returns 400 and is loud. A name that graduated to GA returns
200 and is silent, and the only read-only evidence of it is that the same GET
returns a different shape with the header than without it.
What a 200 proves is narrow and the script says so: the name is recognised by
the request layer. It is not evidence that the beta still does anything on
/v1/messages, and nothing here claims that it is.
"""
import argparse
import json
import logging
import os
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("anthropic_beta_header_audit")
API = "https://api.anthropic.com/v1"
VERSION = "2023-06-01"
# The beta names the Models API reference publishes as accepted values. This is
# a dictionary for near-matching a rejected string, never a verdict: it is a
# document, documents lag, and the probe is the authority. A name the API
# accepts that is missing from here is reported as the list being behind.
KNOWN_BETAS = (
"message-batches-2024-09-24", "prompt-caching-2024-07-31",
"computer-use-2024-10-22", "computer-use-2025-01-24",
"computer-use-2025-11-24", "pdfs-2024-09-25",
"token-counting-2024-11-01", "token-efficient-tools-2025-02-19",
"output-128k-2025-02-19", "output-300k-2026-03-24",
"files-api-2025-04-14", "mcp-client-2025-04-04", "mcp-client-2025-11-20",
"mcp-tunnels-2026-06-22", "dev-full-thinking-2025-05-14",
"interleaved-thinking-2025-05-14", "code-execution-2025-05-22",
"extended-cache-ttl-2025-04-11", "context-1m-2025-08-07",
"context-management-2025-06-27",
"model-context-window-exceeded-2025-08-26", "skills-2025-10-02",
"fast-mode-2026-02-01", "user-profiles-2026-03-24",
"user-profiles-2026-08-18", "advisor-tool-2026-03-01",
"managed-agents-2026-04-01", "agent-memory-2026-07-22",
"cache-diagnosis-2026-04-07", "dreaming-2026-04-21",
"thinking-token-count-2026-05-13", "thinking-display-updates-2026-08-18",
"server-side-fallback-2026-06-01", "server-side-fallback-2026-07-01",
"fallback-credit-2026-06-01", "fallback-credit-2026-07-01",
"mid-conversation-tool-changes-2026-07-01", "compact-2026-01-12",
"structured-outputs-2025-11-13", "task-budgets-2026-03-13",
"ce-user-management-2026-07-13",
)
# Endpoint-scoped betas that are not freely combinable. On memory store
# endpoints the first replaces the second and sending both returns 400.
CONFLICTS = (("agent-memory-2026-07-22", "managed-agents-2026-04-01"),)
# The two listings this script can read with a workspace key. Both are GETs and
# both are free. They are the entire evidence base for the shape comparison,
# which is why "no difference here" is reported as a limit and not as health.
DIFF_PATHS = ("/models", "/files")
FINDINGS = ("rejected-typo", "rejected-unknown", "pinned-to-beta-shape",
"conflicting-pair", "malformed-header")
def split_betas(raw):
"""(names, faults) from one anthropic-beta header value. Pure.
Multiple betas travel in one comma-separated header, so the string itself
can be wrong in ways that have nothing to do with spelling: a trailing
comma leaves an empty segment, a duplicate is silently pointless, and an
embedded space is not part of any documented name.
"""
names = []
faults = []
seen = set()
for segment in str(raw or "").split(","):
piece = segment.strip()
if not piece:
if segment or str(raw or "").count(","):
faults.append("an empty segment, usually a trailing comma")
continue
if piece != piece.lower():
faults.append("%r is not lower case; beta names are exact" % piece)
piece = piece.lower()
if " " in piece or "\t" in piece:
faults.append("%r contains whitespace inside the name" % piece)
if piece in seen:
faults.append("%r is listed more than once" % piece)
continue
seen.add(piece)
names.append(piece)
# De-duplicated, order preserved, so the printed report is stable.
return (tuple(names), tuple(dict.fromkeys(faults)))
def load_call_sites(raw):
"""{call site: raw header value}. Pure. Accepts JSON, a list or a string.
Config lives in different shapes in different repositories and none of them
is worth an argument, so all three are read and a value that cannot be
parsed becomes one anonymous call site rather than an exception.
"""
text = str(raw or "").strip()
if not text:
return {}
try:
parsed = json.loads(text)
except ValueError:
return {"(declared)": text}
if isinstance(parsed, dict):
return {str(k): str(v) for k, v in parsed.items()}
if isinstance(parsed, list):
return {"(declared)": ",".join(str(v) for v in parsed)}
return {"(declared)": str(parsed)}
def levenshtein(a, b):
"""Edit distance between two strings. Pure.
Written out rather than imported so the Python and Node.js versions rank
candidates identically. A suggestion that differs between the two scripts
is a suggestion nobody trusts.
"""
a, b = str(a or ""), str(b or "")
if a == b:
return 0
if not a:
return len(b)
if not b:
return len(a)
previous = list(range(len(b) + 1))
for i, ca in enumerate(a, start=1):
current = [i]
for j, cb in enumerate(b, start=1):
current.append(min(previous[j] + 1,
current[j - 1] + 1,
previous[j - 1] + (0 if ca == cb else 1)))
previous = current
return previous[-1]
def near_matches(name, known=KNOWN_BETAS, limit=3, max_distance=6):
"""The closest documented names to a rejected string. Pure.
Sorted by distance then alphabetically so the output does not reshuffle
between runs. Empty when nothing is close, because a list of unrelated beta
names is worse than no suggestion at all.
"""
scored = []
for candidate in known or ():
distance = levenshtein(name, candidate)
if distance <= max_distance:
scored.append((distance, candidate))
scored.sort()
return tuple(candidate for _, candidate in scored[:limit])
def classify_probe(name, status, known=KNOWN_BETAS):
"""What one probe of one beta name means. Pure. Returns (state, detail).
The 400 is deliberately not resolved into one cause. An invalid name and a
beta this organization is not entitled to return the same documented
message, and picking one would be an invention.
"""
if status is None:
return ("unreachable", "no response, so this name was not graded")
status = int(status)
documented = name in set(known or ())
if status == 200:
if documented:
return ("accepted", "200, and the published enum lists it")
return ("accepted-undocumented",
"200, but the published enum does not list it. The endpoint "
"accepts it, so the list is behind rather than the header "
"being wrong")
if status == 400:
return ("rejected-typo" if near_matches(name, known) else "rejected-unknown",
"400. Invalid, or a beta this organization is not entitled to; "
"the API returns the same message for both")
if status in (401, 403):
return ("credentials",
"%d, which is the key rather than the beta name" % status)
return ("unexpected", "%d" % status)
def key_sets(payload):
"""(top-level keys, keys on the first data item). Pure.
Two granularities because the documented graduation differences live at
both: pagination cursors move at the top level, and expires_at appears on
the individual objects.
"""
body = payload if isinstance(payload, dict) else {}
top = tuple(sorted(str(k) for k in body.keys()))
data = body.get("data")
first = data[0] if isinstance(data, list) and data else None
item = tuple(sorted(str(k) for k in first.keys())) if isinstance(first, dict) else ()
return (top, item)
def shape_delta(with_header, without_header):
"""Which keys differ between two bodies. Pure.
{"top": (only_with, only_without), "item": (only_with, only_without)}.
Sets rather than a diff of values: a beta header changes which fields exist,
and comparing values would report every id and timestamp as a difference.
"""
w_top, w_item = key_sets(with_header)
n_top, n_item = key_sets(without_header)
return {
"top": (tuple(sorted(set(w_top) - set(n_top))),
tuple(sorted(set(n_top) - set(w_top)))),
"item": (tuple(sorted(set(w_item) - set(n_item))),
tuple(sorted(set(n_item) - set(w_item)))),
}
def graduation_verdict(name, deltas):
"""Grade one accepted name by response shape. Pure. Returns (state, detail).
deltas: {path: shape_delta(...)}. An identical pair is reported as a limit
of the test rather than as a clean bill of health, because only some betas
change a readable GET and this script can read exactly two of them.
"""
changed = []
for path in sorted(deltas or {}):
delta = (deltas or {})[path] or {}
if any(delta.get(scope, ((), ()))[side]
for scope in ("top", "item") for side in (0, 1)):
changed.append(path)
if changed:
return ("pinned-to-beta-shape",
"accepted, and the response differs with and without it on: "
+ ", ".join(changed))
return ("no-visible-difference",
"same keys with and without it on the endpoints this script can "
"read, which is not evidence that the header does nothing")
def conflicting(names):
"""[(a, b)] documented pairs present together. Pure."""
have = set(str(n).strip().lower() for n in names or ())
return [pair for pair in CONFLICTS if have.issuperset(pair)]
def repair_lines(state, name=None, matches=(), deltas=None):
"""The repair for one verdict. Pure. Printed, never performed."""
if state == "rejected-typo":
return ["replace it with %s, then re-run this probe."
% (matches[0] if matches else "the documented name"),
"if the spelling is already exact, the other cause is "
"entitlement: the same 400 is returned for a beta this "
"organization does not have access to."]
if state == "rejected-unknown":
return ["nothing in the published enum is close to %r. Read the beta "
"headers reference for the current name, and check entitlement "
"before assuming it is a typo." % str(name)]
if state == "pinned-to-beta-shape":
lines = ["the beta graduated. The header is optional now and it is not "
"inert: it holds this client on the response shape it shipped "
"with. Read the migration notes before dropping it."]
for path in sorted(deltas or {}):
delta = (deltas or {})[path] or {}
only_with, only_without = delta.get("top", ((), ()))
if only_with:
lines.append("%s top-level keys only with the header: %s"
% (path, ", ".join(only_with)))
if only_without:
lines.append("%s top-level keys only without it: %s"
% (path, ", ".join(only_without)))
i_with, i_without = delta.get("item", ((), ()))
if i_with:
lines.append("%s item keys only with the header: %s"
% (path, ", ".join(i_with)))
if i_without:
lines.append("%s item keys only without it: %s"
% (path, ", ".join(i_without)))
return lines
if state == "conflicting-pair":
return ["on memory store endpoints the first replaces the second. "
"Sending both returns 400. Send agent-memory-2026-07-22 alone "
"there and keep managed-agents-2026-04-01 for the agent, "
"session and environment endpoints."]
if state == "malformed-header":
return ["multiple betas go in one comma separated header. Rebuild the "
"string from a list rather than concatenating, and note that "
"repeating a --beta flag on the CLI keeps only the first."]
return []
def get(session, path, headers=None, params=None, timeout=60):
"""One GET. Returns (status, parsed body or None). Never raises on a 4xx.
A 400 is the expected answer to half the probes here and is the most
informative result the script can get, so it is data rather than an error.
"""
try:
r = session.get(API + path, headers=headers or {},
params=params or {}, timeout=timeout)
except requests.RequestException as exc:
log.debug("GET %s failed: %s", path, exc)
return (None, None)
try:
return (r.status_code, r.json())
except ValueError:
return (r.status_code, None)
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--beta", action="append", default=[],
help="a beta name your code sends (repeatable)")
ap.add_argument("--skip-shape-diff", action="store_true",
help="probe validity only and do not compare responses")
args = ap.parse_args()
key = os.environ.get("ANTHROPIC_API_KEY")
if not key:
log.error("set ANTHROPIC_API_KEY to a workspace key. This script only "
"issues GET requests")
return 2
call_sites = load_call_sites(os.environ.get("ANTHROPIC_BETA_HEADERS"))
if args.beta:
call_sites.setdefault("(command line)", ",".join(args.beta))
if not call_sites:
log.error("nothing to grade. Set ANTHROPIC_BETA_HEADERS to a JSON map "
"of call site to header value, or pass --beta")
return 2
session = requests.Session()
session.headers.update({"x-api-key": key, "anthropic-version": VERSION})
findings = 0
distinct = []
for site in sorted(call_sites):
names, faults = split_betas(call_sites[site])
for name in names:
if name not in distinct:
distinct.append(name)
for fault in faults:
log.warning("%-20s %s sends %s", "malformed-header", site, fault)
findings += 1
if faults:
for line in repair_lines("malformed-header"):
log.warning(" repair: %s", line)
for pair in conflicting(names):
log.warning("%-20s %s sends %s with %s", "conflicting-pair", site,
pair[0], pair[1])
for line in repair_lines("conflicting-pair"):
log.warning(" repair: %s", line)
findings += 1
log.info("%d distinct beta string(s) across %d call site(s)",
len(distinct), len(call_sites))
for name in distinct:
status, _ = get(session, "/models",
headers={"anthropic-beta": name}, params={"limit": 1})
state, detail = classify_probe(name, status)
emit = log.warning if state in FINDINGS else log.info
emit("%-20s %s: %s", state, name, detail)
matches = near_matches(name) if state.startswith("rejected") else ()
if matches:
emit(" closest documented names: %s", ", ".join(matches))
for line in repair_lines(state, name, matches):
emit(" repair: %s", line)
if state in FINDINGS:
findings += 1
continue
if state not in ("accepted", "accepted-undocumented") or args.skip_shape_diff:
continue
deltas = {}
for path in DIFF_PATHS:
with_status, with_body = get(session, path,
headers={"anthropic-beta": name},
params={"limit": 1})
without_status, without_body = get(session, path, params={"limit": 1})
if with_status != 200 or without_status != 200:
continue
deltas[path] = shape_delta(with_body, without_body)
if not deltas:
log.info(" neither listing was readable, so no shape comparison "
"was made for this name")
continue
state, detail = graduation_verdict(name, deltas)
emit = log.warning if state in FINDINGS else log.info
emit("%-20s %s: %s", state, name, detail)
for line in repair_lines(state, name, (), deltas):
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())
/**
* Grade every anthropic-beta string your code sends, without sending one.
*
* Read only. Every request is a GET: /v1/models to validate a name, and the
* two readable listings twice each to compare a response with and without a
* header. No request body is constructed and nothing is generated or billed.
*
* A 200 proves the name is recognised by the request layer. It is not evidence
* that the beta still does anything on /v1/messages, and nothing here says so.
*/
const API = 'https://api.anthropic.com/v1';
const VERSION = '2023-06-01';
// A dictionary for near-matching a rejected string, never a verdict. The probe
// is the authority; a document lags.
export const KNOWN_BETAS = [
'message-batches-2024-09-24', 'prompt-caching-2024-07-31',
'computer-use-2024-10-22', 'computer-use-2025-01-24',
'computer-use-2025-11-24', 'pdfs-2024-09-25',
'token-counting-2024-11-01', 'token-efficient-tools-2025-02-19',
'output-128k-2025-02-19', 'output-300k-2026-03-24',
'files-api-2025-04-14', 'mcp-client-2025-04-04', 'mcp-client-2025-11-20',
'mcp-tunnels-2026-06-22', 'dev-full-thinking-2025-05-14',
'interleaved-thinking-2025-05-14', 'code-execution-2025-05-22',
'extended-cache-ttl-2025-04-11', 'context-1m-2025-08-07',
'context-management-2025-06-27',
'model-context-window-exceeded-2025-08-26', 'skills-2025-10-02',
'fast-mode-2026-02-01', 'user-profiles-2026-03-24',
'user-profiles-2026-08-18', 'advisor-tool-2026-03-01',
'managed-agents-2026-04-01', 'agent-memory-2026-07-22',
'cache-diagnosis-2026-04-07', 'dreaming-2026-04-21',
'thinking-token-count-2026-05-13', 'thinking-display-updates-2026-08-18',
'server-side-fallback-2026-06-01', 'server-side-fallback-2026-07-01',
'fallback-credit-2026-06-01', 'fallback-credit-2026-07-01',
'mid-conversation-tool-changes-2026-07-01', 'compact-2026-01-12',
'structured-outputs-2025-11-13', 'task-budgets-2026-03-13',
'ce-user-management-2026-07-13',
];
// On memory store endpoints the first replaces the second; both is a 400.
export const CONFLICTS = [['agent-memory-2026-07-22', 'managed-agents-2026-04-01']];
const DIFF_PATHS = ['/models', '/files'];
const FINDINGS = new Set(['rejected-typo', 'rejected-unknown',
'pinned-to-beta-shape', 'conflicting-pair', 'malformed-header']);
/** [names, faults] from one anthropic-beta header value. Pure. */
export function splitBetas(raw) {
const names = [];
const faults = [];
const seen = new Set();
const text = String(raw ?? '');
for (const segment of text.split(',')) {
let piece = segment.trim();
if (!piece) {
if (segment || text.includes(',')) {
faults.push('an empty segment, usually a trailing comma');
}
continue;
}
if (piece !== piece.toLowerCase()) {
faults.push(`'${piece}' is not lower case; beta names are exact`);
piece = piece.toLowerCase();
}
if (piece.includes(' ') || piece.includes('\t')) {
faults.push(`'${piece}' contains whitespace inside the name`);
}
if (seen.has(piece)) {
faults.push(`'${piece}' is listed more than once`);
continue;
}
seen.add(piece);
names.push(piece);
}
return [names, [...new Set(faults)]];
}
/** {call site: raw header value}. Pure. Accepts JSON, a list or a string. */
export function loadCallSites(raw) {
const text = String(raw ?? '').trim();
if (!text) return {};
let parsed;
try {
parsed = JSON.parse(text);
} catch {
return { '(declared)': text };
}
if (Array.isArray(parsed)) return { '(declared)': parsed.map(String).join(',') };
if (parsed && typeof parsed === 'object') {
return Object.fromEntries(Object.entries(parsed).map(([k, v]) => [String(k), String(v)]));
}
return { '(declared)': String(parsed) };
}
/** Edit distance between two strings. Pure. Written out to match the Python. */
export function levenshtein(a, b) {
const x = String(a ?? '');
const y = String(b ?? '');
if (x === y) return 0;
if (!x.length) return y.length;
if (!y.length) return x.length;
let previous = Array.from({ length: y.length + 1 }, (_, i) => i);
for (let i = 1; i <= x.length; i += 1) {
const current = [i];
for (let j = 1; j <= y.length; j += 1) {
current.push(Math.min(previous[j] + 1, current[j - 1] + 1,
previous[j - 1] + (x[i - 1] === y[j - 1] ? 0 : 1)));
}
previous = current;
}
return previous[y.length];
}
/** The closest documented names to a rejected string. Pure. */
export function nearMatches(name, known = KNOWN_BETAS, limit = 3, maxDistance = 6) {
const scored = [];
for (const candidate of known ?? []) {
const distance = levenshtein(name, candidate);
if (distance <= maxDistance) scored.push([distance, candidate]);
}
scored.sort((a, b) => (a[0] - b[0]) || a[1].localeCompare(b[1]));
return scored.slice(0, limit).map(([, candidate]) => candidate);
}
/** What one probe of one beta name means. Pure. Returns [state, detail]. */
export function classifyProbe(name, status, known = KNOWN_BETAS) {
if (status === null || status === undefined) {
return ['unreachable', 'no response, so this name was not graded'];
}
const code = Math.trunc(Number(status));
const documented = (known ?? []).includes(name);
if (code === 200) {
if (documented) return ['accepted', '200, and the published enum lists it'];
return ['accepted-undocumented',
'200, but the published enum does not list it. The endpoint accepts it, '
+ 'so the list is behind rather than the header being wrong'];
}
if (code === 400) {
return [nearMatches(name, known).length ? 'rejected-typo' : 'rejected-unknown',
'400. Invalid, or a beta this organization is not entitled to; the API '
+ 'returns the same message for both'];
}
if (code === 401 || code === 403) {
return ['credentials', `${code}, which is the key rather than the beta name`];
}
return ['unexpected', `${code}`];
}
/** [top-level keys, keys on the first data item]. Pure. */
export function keySets(payload) {
const body = (payload && typeof payload === 'object' && !Array.isArray(payload))
? payload : {};
const top = Object.keys(body).map(String).sort();
const data = body.data;
const first = Array.isArray(data) && data.length ? data[0] : null;
const item = (first && typeof first === 'object')
? Object.keys(first).map(String).sort() : [];
return [top, item];
}
/** Which keys differ between two bodies. Pure. */
export function shapeDelta(withHeader, withoutHeader) {
const [wTop, wItem] = keySets(withHeader);
const [nTop, nItem] = keySets(withoutHeader);
const only = (a, b) => a.filter((k) => !b.includes(k)).sort();
return {
top: [only(wTop, nTop), only(nTop, wTop)],
item: [only(wItem, nItem), only(nItem, wItem)],
};
}
/** Grade one accepted name by response shape. Pure. Returns [state, detail]. */
export function graduationVerdict(name, deltas) {
const changed = [];
for (const path of Object.keys(deltas ?? {}).sort()) {
const delta = (deltas ?? {})[path] ?? {};
const groups = [delta.top ?? [[], []], delta.item ?? [[], []]];
if (groups.some((g) => g.some((side) => (side ?? []).length))) changed.push(path);
}
if (changed.length) {
return ['pinned-to-beta-shape',
'accepted, and the response differs with and without it on: ' + changed.join(', ')];
}
return ['no-visible-difference',
'same keys with and without it on the endpoints this script can read, which '
+ 'is not evidence that the header does nothing'];
}
/** [[a, b]] documented pairs present together. Pure. */
export function conflicting(names) {
const have = new Set((names ?? []).map((n) => String(n).trim().toLowerCase()));
return CONFLICTS.filter((pair) => pair.every((n) => have.has(n)));
}
/** The repair for one verdict. Pure. Printed, never performed. */
export function repairLines(state, name = null, matches = [], deltas = null) {
if (state === 'rejected-typo') {
return [`replace it with ${matches[0] ?? 'the documented name'}, then re-run this probe.`,
'if the spelling is already exact, the other cause is entitlement: the '
+ 'same 400 is returned for a beta this organization does not have access to.'];
}
if (state === 'rejected-unknown') {
return [`nothing in the published enum is close to '${name}'. Read the beta `
+ 'headers reference for the current name, and check entitlement before '
+ 'assuming it is a typo.'];
}
if (state === 'pinned-to-beta-shape') {
const lines = ['the beta graduated. The header is optional now and it is not '
+ 'inert: it holds this client on the response shape it shipped with. Read '
+ 'the migration notes before dropping it.'];
for (const path of Object.keys(deltas ?? {}).sort()) {
const delta = (deltas ?? {})[path] ?? {};
const [onlyWith, onlyWithout] = delta.top ?? [[], []];
if (onlyWith.length) lines.push(`${path} top-level keys only with the header: ${onlyWith.join(', ')}`);
if (onlyWithout.length) lines.push(`${path} top-level keys only without it: ${onlyWithout.join(', ')}`);
const [iWith, iWithout] = delta.item ?? [[], []];
if (iWith.length) lines.push(`${path} item keys only with the header: ${iWith.join(', ')}`);
if (iWithout.length) lines.push(`${path} item keys only without it: ${iWithout.join(', ')}`);
}
return lines;
}
if (state === 'conflicting-pair') {
return ['on memory store endpoints the first replaces the second. Sending '
+ 'both returns 400. Send agent-memory-2026-07-22 alone there and keep '
+ 'managed-agents-2026-04-01 for the agent, session and environment endpoints.'];
}
if (state === 'malformed-header') {
return ['multiple betas go in one comma separated header. Rebuild the string '
+ 'from a list rather than concatenating, and note that repeating a --beta '
+ 'flag on the CLI keeps only the first.'];
}
return [];
}
async function read(key, path, beta, params = { limit: 1 }) {
const url = new URL(API + path);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, String(v));
const headers = { 'x-api-key': key, 'anthropic-version': VERSION };
if (beta) headers['anthropic-beta'] = beta;
try {
const r = await fetch(url, { headers });
let body = null;
try { body = await r.json(); } catch { body = null; }
return [r.status, body];
} catch {
return [null, null];
}
}
async function main() {
const key = process.env.ANTHROPIC_API_KEY;
if (!key) {
console.error('set ANTHROPIC_API_KEY to a workspace key. This script only '
+ 'issues GET requests');
process.exitCode = 2;
return;
}
const callSites = loadCallSites(process.env.ANTHROPIC_BETA_HEADERS);
if (!Object.keys(callSites).length) {
console.error('nothing to grade. Set ANTHROPIC_BETA_HEADERS to a JSON map of '
+ 'call site to header value');
process.exitCode = 2;
return;
}
let findings = 0;
const distinct = [];
for (const site of Object.keys(callSites).sort()) {
const [names, faults] = splitBetas(callSites[site]);
for (const name of names) if (!distinct.includes(name)) distinct.push(name);
for (const fault of faults) {
console.log(`${'malformed-header'.padEnd(20)} ${site} sends ${fault}`);
findings += 1;
}
if (faults.length) {
for (const line of repairLines('malformed-header')) console.log(` repair: ${line}`);
}
for (const pair of conflicting(names)) {
console.log(`${'conflicting-pair'.padEnd(20)} ${site} sends ${pair[0]} with ${pair[1]}`);
for (const line of repairLines('conflicting-pair')) console.log(` repair: ${line}`);
findings += 1;
}
}
console.log(`${distinct.length} distinct beta string(s) across `
+ `${Object.keys(callSites).length} call site(s)`);
for (const name of distinct) {
const [status] = await read(key, '/models', name);
const [state, detail] = classifyProbe(name, status);
console.log(`${state.padEnd(20)} ${name}: ${detail}`);
const matches = state.startsWith('rejected') ? nearMatches(name) : [];
if (matches.length) console.log(` closest documented names: ${matches.join(', ')}`);
for (const line of repairLines(state, name, matches)) console.log(` repair: ${line}`);
if (FINDINGS.has(state)) { findings += 1; continue; }
if (state !== 'accepted' && state !== 'accepted-undocumented') continue;
const deltas = {};
for (const path of DIFF_PATHS) {
const [withStatus, withBody] = await read(key, path, name);
const [withoutStatus, withoutBody] = await read(key, path, null);
if (withStatus !== 200 || withoutStatus !== 200) continue;
deltas[path] = shapeDelta(withBody, withoutBody);
}
if (!Object.keys(deltas).length) {
console.log(' neither listing was readable, so no shape comparison was '
+ 'made for this name');
continue;
}
const [gstate, gdetail] = graduationVerdict(name, deltas);
console.log(`${gstate.padEnd(20)} ${name}: ${gdetail}`);
for (const line of repairLines(gstate, name, [], deltas)) console.log(` repair: ${line}`);
if (FINDINGS.has(gstate)) 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 loud half: a name one letter away from a documented beta comes back rejected and carries the correct suggestion, and the same rejection also states the entitlement possibility, because the API returns one message for two causes. The second is the quiet half, built from the documented Files API migration table — has_more/first_id/last_id with the header, next_page and expires_at without it — and it has to come back as a finding naming those exact keys. The third is the one that keeps the script honest: two identical bodies must produce no-visible-difference and must not be a finding, with a detail line saying the test could not tell rather than that the header is fine. Then the enum lagging behind the endpoint, the structural faults in a comma-separated header, and the one documented pair that must not travel together.
from anthropic_beta_header_audit import (classify_probe, conflicting,
graduation_verdict, key_sets,
levenshtein, load_call_sites,
near_matches, repair_lines,
shape_delta, split_betas)
def files_listing(beta):
"""The documented Files API shapes, with and without files-api-2025-04-14."""
if beta:
return {"data": [{"id": "file_01", "type": "file", "size_bytes": 12}],
"has_more": False, "first_id": "file_01", "last_id": "file_01"}
return {"data": [{"id": "file_01", "type": "file", "size_bytes": 12,
"expires_at": None}],
"next_page": None}
def test_a_misspelled_beta_is_rejected_and_the_suggestion_is_the_repair():
state, detail = classify_probe("contxt-1m-2025-08-07", 400)
assert state == "rejected-typo"
# One message, two causes, and the script refuses to pick between them.
assert "not entitled to" in detail
matches = near_matches("contxt-1m-2025-08-07")
assert matches[0] == "context-1m-2025-08-07"
lines = repair_lines(state, "contxt-1m-2025-08-07", matches)
assert any("context-1m-2025-08-07" in line for line in lines)
assert any("entitlement" in line for line in lines)
def test_a_graduated_beta_returns_200_and_pins_the_older_shape():
# The documented Files API migration table, asserted as a diff.
deltas = {"/files": shape_delta(files_listing(True), files_listing(False))}
state, detail = graduation_verdict("files-api-2025-04-14", deltas)
assert state == "pinned-to-beta-shape"
assert "/files" in detail
assert deltas["/files"]["top"][0] == ("first_id", "has_more", "last_id")
assert deltas["/files"]["top"][1] == ("next_page",)
assert deltas["/files"]["item"][1] == ("expires_at",)
lines = repair_lines(state, "files-api-2025-04-14", (), deltas)
assert any("expires_at" in line for line in lines)
assert any("graduated" in line for line in lines)
def test_identical_bodies_prove_nothing_and_are_not_a_finding():
same = {"data": [{"id": "m1"}], "has_more": False}
deltas = {"/models": shape_delta(same, same)}
state, detail = graduation_verdict("context-management-2025-06-27", deltas)
assert state == "no-visible-difference"
assert "not evidence that the header does nothing" in detail
assert repair_lines(state) == []
def test_the_published_enum_is_a_dictionary_and_not_the_verdict():
state, detail = classify_probe("brand-new-beta-2026-09-01", 200)
assert state == "accepted-undocumented"
assert "the list is behind" in detail
assert classify_probe("files-api-2025-04-14", 200)[0] == "accepted"
assert near_matches("nothing-like-a-beta-name") == ()
assert classify_probe("nothing-like-a-beta-name", 400)[0] == "rejected-unknown"
assert levenshtein("abc", "abc") == 0 and levenshtein("", "abc") == 3
def test_the_header_is_one_string_carrying_a_list_so_it_can_be_malformed():
names, faults = split_betas("files-api-2025-04-14, skills-2025-10-02,")
assert names == ("files-api-2025-04-14", "skills-2025-10-02")
assert any("trailing comma" in f for f in faults)
names, faults = split_betas("Skills-2025-10-02,skills-2025-10-02")
assert names == ("skills-2025-10-02",)
assert any("lower case" in f for f in faults)
assert any("more than once" in f for f in faults)
names, faults = split_betas("files api 2025-04-14")
assert any("whitespace inside" in f for f in list(names) + list(faults))
assert any("comma separated" in line
for line in repair_lines("malformed-header"))
def test_the_documented_conflicting_pair_needs_no_request_at_all():
assert conflicting(["agent-memory-2026-07-22",
"managed-agents-2026-04-01"]) == [
("agent-memory-2026-07-22", "managed-agents-2026-04-01")]
assert conflicting(["managed-agents-2026-04-01"]) == []
assert any("replaces the second" in line
for line in repair_lines("conflicting-pair"))
def test_input_and_bodies_are_read_in_whatever_shape_they_arrive():
assert load_call_sites('{"a.py": "x,y"}') == {"a.py": "x,y"}
assert load_call_sites('["x", "y"]') == {"(declared)": "x,y"}
assert load_call_sites("x,y") == {"(declared)": "x,y"}
assert load_call_sites("") == {}
assert key_sets(None) == ((), ())
assert key_sets({"data": []}) == (("data",), ())
assert classify_probe("files-api-2025-04-14", None)[0] == "unreachable"
assert classify_probe("files-api-2025-04-14", 401)[0] == "credentials"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { classifyProbe, conflicting, graduationVerdict, keySets, levenshtein,
loadCallSites, nearMatches, repairLines, shapeDelta,
splitBetas } from './anthropic-beta-header-audit.mjs';
const filesListing = (beta) => (beta
? { data: [{ id: 'file_01', type: 'file', size_bytes: 12 }],
has_more: false, first_id: 'file_01', last_id: 'file_01' }
: { data: [{ id: 'file_01', type: 'file', size_bytes: 12, expires_at: null }],
next_page: null });
test('a misspelled beta is rejected and the suggestion is the repair', () => {
const [state, detail] = classifyProbe('contxt-1m-2025-08-07', 400);
assert.equal(state, 'rejected-typo');
assert.ok(detail.includes('not entitled to'));
const matches = nearMatches('contxt-1m-2025-08-07');
assert.equal(matches[0], 'context-1m-2025-08-07');
const lines = repairLines(state, 'contxt-1m-2025-08-07', matches);
assert.ok(lines.some((l) => l.includes('context-1m-2025-08-07')));
assert.ok(lines.some((l) => l.includes('entitlement')));
});
test('a graduated beta returns 200 and pins the older shape', () => {
const deltas = { '/files': shapeDelta(filesListing(true), filesListing(false)) };
const [state, detail] = graduationVerdict('files-api-2025-04-14', deltas);
assert.equal(state, 'pinned-to-beta-shape');
assert.ok(detail.includes('/files'));
assert.deepEqual(deltas['/files'].top[0], ['first_id', 'has_more', 'last_id']);
assert.deepEqual(deltas['/files'].top[1], ['next_page']);
assert.deepEqual(deltas['/files'].item[1], ['expires_at']);
const lines = repairLines(state, 'files-api-2025-04-14', [], deltas);
assert.ok(lines.some((l) => l.includes('expires_at')));
assert.ok(lines.some((l) => l.includes('graduated')));
});
test('identical bodies prove nothing and are not a finding', () => {
const same = { data: [{ id: 'm1' }], has_more: false };
const deltas = { '/models': shapeDelta(same, same) };
const [state, detail] = graduationVerdict('context-management-2025-06-27', deltas);
assert.equal(state, 'no-visible-difference');
assert.ok(detail.includes('not evidence that the header does nothing'));
assert.deepEqual(repairLines(state), []);
});
test('the published enum is a dictionary and not the verdict', () => {
const [state, detail] = classifyProbe('brand-new-beta-2026-09-01', 200);
assert.equal(state, 'accepted-undocumented');
assert.ok(detail.includes('the list is behind'));
assert.equal(classifyProbe('files-api-2025-04-14', 200)[0], 'accepted');
assert.deepEqual(nearMatches('nothing-like-a-beta-name'), []);
assert.equal(classifyProbe('nothing-like-a-beta-name', 400)[0], 'rejected-unknown');
assert.equal(levenshtein('abc', 'abc'), 0);
assert.equal(levenshtein('', 'abc'), 3);
});
test('the header is one string carrying a list so it can be malformed', () => {
let [names, faults] = splitBetas('files-api-2025-04-14, skills-2025-10-02,');
assert.deepEqual(names, ['files-api-2025-04-14', 'skills-2025-10-02']);
assert.ok(faults.some((f) => f.includes('trailing comma')));
[names, faults] = splitBetas('Skills-2025-10-02,skills-2025-10-02');
assert.deepEqual(names, ['skills-2025-10-02']);
assert.ok(faults.some((f) => f.includes('lower case')));
assert.ok(faults.some((f) => f.includes('more than once')));
[names, faults] = splitBetas('files api 2025-04-14');
assert.ok([...names, ...faults].some((f) => f.includes('whitespace inside')));
assert.ok(repairLines('malformed-header').some((l) => l.includes('comma separated')));
});
test('the documented conflicting pair needs no request at all', () => {
assert.deepEqual(conflicting(['agent-memory-2026-07-22', 'managed-agents-2026-04-01']),
[['agent-memory-2026-07-22', 'managed-agents-2026-04-01']]);
assert.deepEqual(conflicting(['managed-agents-2026-04-01']), []);
assert.ok(repairLines('conflicting-pair').some((l) => l.includes('replaces the second')));
});
test('input and bodies are read in whatever shape they arrive', () => {
assert.deepEqual(loadCallSites('{"a.py": "x,y"}'), { 'a.py': 'x,y' });
assert.deepEqual(loadCallSites('["x", "y"]'), { '(declared)': 'x,y' });
assert.deepEqual(loadCallSites('x,y'), { '(declared)': 'x,y' });
assert.deepEqual(loadCallSites(''), {});
assert.deepEqual(keySets(null), [[], []]);
assert.deepEqual(keySets({ data: [] }), [['data'], []]);
assert.equal(classifyProbe('files-api-2025-04-14', null)[0], 'unreachable');
assert.equal(classifyProbe('files-api-2025-04-14', 401)[0], 'credentials');
});
FAQ
A beta name that 400s: is it a typo or a permissions problem?
The API cannot tell you, and neither can this script. The documented response for an unrecognised beta name and for a beta your organization is not entitled to is the same message: Unexpected value(s) for the anthropic-beta header. So the output says both, in one line, and adds the closest documented names when there are any. In practice the near-match resolves it most of the time, because a rejected string one character away from a real beta is a typo and a rejected string with nothing near it is usually an entitlement question for your account team.
How is this different from the note about the obsolete 1M context beta?
That note is about a context window, this one is about a header string, and the boundary is a sentence that note gets right: a 200 from GET /v1/models with a beta header proves the name is recognised and proves nothing about what the beta does on /v1/messages. So it refuses to probe, reads max_input_tokens off the model object instead, and grades your enforced ceiling. This note takes the half that a probe genuinely answers, which is validity, plus one that a pair of probes answers, which is whether the header still changes the shape of a response. Neither script claims the other's evidence.
Why does an accepted beta header need checking at all?
Because acceptance and necessity are different things. Betas graduate, and when they do the header becomes optional but not inert: requests that still send it keep receiving the pre-GA response shape. The Files API graduation is documented as a table of exactly these differences, and the effect on a client that never dropped the header is concrete rather than theoretical. It gets has_more, first_id and last_id instead of next_page, it cannot use the ids[] filter, and it never sees expires_at, which means expiry handling it does not know it is missing.
What does 'no visible difference' actually mean in the output?
That the two endpoints this script can safely read returned the same keys with and without the header. It is a statement about the test, not about the header. Only some betas change a readable GET, most of them change behaviour on /v1/messages instead, and a script that never sends a message cannot see those. Reporting that as healthy would be the same error as treating a 200 as proof of effect, so the state is named for what it is and is not counted as a finding either way.
Can the script find the beta strings in my code for me?
No, and that is a scope decision rather than a limitation of effort. Nothing in either API returns the headers of requests you have already sent, and this section's scripts read APIs rather than source trees. So the strings are declared as input, per call site, and the per-call-site part earns its keep twice: the malformed-string checks and the conflicting-pair check are both properties of one header value, and a flattened set of names loses both of them.
Related field notes
- The other header on the same request, and the matrix that proves it
- A beta header that is valid, accepted, and does nothing
- The model ids in your config, diffed against the model list
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.
- Beta headers — Claude API reference
- List Models — Claude API reference
- Files API, including the files-api-2025-04-14 migration table
- Errors — Claude API reference
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.