Diagnostic LLM APIs
anthropic-version is missing, ancient, or added in transit
The webhook receiver has worked for a year. It is forty lines of fetch written the afternoon somebody needed it, it posts to Claude, and it has never once been touched. Today it returns 400 invalid_request_error on every call and the message is about a header you have never had to think about, because the SDK sets it and this thing is not the SDK. The part worth understanding is not the fix, which is one line. It is why it worked yesterday: the request used to go through the gateway, the gateway added anthropic-version for you, and last week somebody pointed that service straight at the API.
Probe, do not read. There is no endpoint that reports which version header your clients send, so the script measures the behaviour instead: GET /v1/models with a workspace key, three times. Once with no anthropic-version header at all, once with 2023-06-01, once with 2023-01-01. The listing generates nothing and bills nothing, and a 400 from it costs exactly as little as a 200.
No single status is the finding. Absent 400, current 200 is the shape a direct connection should have. Absent 200 means something on that path is supplying the header for you, which is the interesting result, because every client behind it is untested. Current not 200 is a credential problem wearing a versioning costume, and the script says so instead of grading the rest of a matrix it cannot trust.
Then repeat the whole matrix through the base URL your production traffic actually leaves by and diff the two. A gateway that injects the header turns a broken client into a passing one in staging and a 400 storm in production. A gateway that strips or rewrites it does the reverse. Neither is visible from a single host, and that comparison is the reason this note has a script rather than a sentence.
Only two values have ever existed: 2023-01-01, the initial release, and 2023-06-01, the current one. So declare the strings your clients send and the script grades them too. Anything that is not one of those two is a typo or an invention, whatever the probe returns for it.
A client pinned to 2023-01-01 may well still get a 200. That is not reassurance. Previous versions are documented as deprecated and may be unavailable to new users, and the pin freezes you before the 2023-06-01 streaming format: incremental named events, and no data: [DONE].
The problem in plain words
anthropic-version is required on every request to the Claude API. It sits in the same headers table as x-api-key and it is not optional. Every official SDK sets it for you, which is precisely why nobody thinks about it: the header is invisible until somebody writes HTTP by hand, and somebody always does. A webhook receiver, a shell script, a Lambda that could not carry the dependency, an internal proxy, a code sample from a blog post that predates the SDK.
What makes it a field note rather than a footnote is that the fault is not stable across environments. Gateways, service meshes and API management layers add default headers, and adding a sensible anthropic-version to outbound Anthropic traffic is exactly the sort of sensible thing they are configured to do. So the same client is correct behind the gateway and broken beside it, and the discovery event is a deployment that changes which of the two it is.
The ancient pin is quieter and lasts longer. 2023-01-01 is a real version, so it does not necessarily error. It is also the version before streaming got named events, before data: [DONE] was removed, and before the legacy exception and truncated response values were dropped. A client pinned there is being served the old contract, and the version policy explicitly reserves the right to change error conditions and add enum variants between versions, which is the whole point of the header.
And the invented value is worth its own line because it is so easy to produce. 2024-06-01 looks like a version. 2023-06-01 with a trailing space looks identical in a config file. Neither has ever existed, and neither will announce itself as fiction.
Why it happens
One status code is not evidence about a required header. If you send the header and get a 200, you have learned that your request worked, not that the header was needed, not that you sent it, and certainly not that the header reaching Anthropic is the one you set. The claim "this header is required on this path" needs two probes that disagree: absent must fail where present succeeds. That is why the unit here is a matrix and why the script refuses to grade anything else when the current-version probe is not a 200.
The gateway comparison is the finding, not a nicety. A header injected in transit is undetectable from either side alone. From the client you see a 200. From the API you see a valid request. The only thing that reveals it is the same probe issued twice down two paths, and the result you are looking for is the pleasant one: staging is fine. That is the environment where a broken client passes review.
Acceptance of 2023-01-01 is not permission to keep it. The probe reports what the host does today. The documentation says previous versions are deprecated and may be unavailable for new users, and a version pin freezes error-condition and enum behaviour that Anthropic reserves the right to evolve. So the script reports the status and grades the pin as a finding regardless of it, because those are two different questions and only one of them is answered by a number.
This note owns a header and not a model id. There is a published note that diffs the model strings in your configuration against the model list, and another that reads shutdown dates. Nothing here touches either. The version header is a property of the client, it fails identically on every model, and it fails before any model is selected — which is also why GET /v1/models is the right endpoint to prove it on.
A declared version string is graded even when the probe likes it. The API is not the authority on whether your configuration is sensible; the documented version history is. Two values exist. A script that only reported non-200s would wave through a client pinned to 2023-01-01 that works today and is one deprecation away from not, and would wave through 2024-06-01 as long as some host somewhere was lenient about it.
The fix, as a flow
One status code cannot tell you anything about a required header, because you have no way of knowing what your client, your SDK and whatever sits between them each contributed to it. The reading here is a matrix: the same endpoint called with no version header, with the current one and with the 2023 one, then the whole matrix repeated through the gateway your production traffic actually leaves by. The finding is in the disagreements, not in any single row.
How to fix it
Use a workspace key, and know what it can do
Anthropic has no read-only tier on the data plane: the same workspace key that reads /v1/models could send a message. This script is trusted not to rather than prevented from it, and it makes only GETs. An Admin key is the wrong credential here and cannot reach the models endpoint at all.
Run the three probes against the API directly
GET https://api.anthropic.com/v1/models with no anthropic-version header, then with 2023-06-01, then with 2023-01-01. Record the status codes; do not raise on a 400. The listing costs nothing either way, which is the reason a deliberately failing probe is acceptable here at all.
Declare the version strings your own clients send
ANTHROPIC_VERSIONS as a comma-separated list, or repeated --version. Take them out of the deployed configuration, not from memory. Each one is probed and graded against the documented version history, which has exactly two entries.
Repeat the matrix through your gateway base URL
--gateway, or ANTHROPIC_BASE_URL. The same three probes down the path production actually uses. The script diffs the two matrices and names the difference: injected, stripped, or merely disagreeing.
Read the disagreements, then print the repair
Absent-200 anywhere means a header is being added for you. Current-non-200 through the gateway means one is being removed. The repair is always the same line — send anthropic-version: 2023-06-01 from the client itself, or drop the hand-rolled HTTP for the SDK, which sets it — and the script prints it without applying it.
How to check it worked
Fix the client and re-run. The direct matrix should not move at all, because it never described your client; the gateway matrix should now agree with it. The re-run that matters is the one from the environment that was passing, since that is where an injected header was doing the work you thought your code was doing.
ANTHROPIC_VERSIONS=2023-06-01,2023-01-01 \
python3 anthropic_version_header_probe.py --gateway https://llm-gw.internal
# host https://api.anthropic.com
# (absent) 400 enforced 400 with no version header, which is correct
# 2023-06-01 200 accepted 200, the current version
# 2023-01-01 200 accepted-deprecated 200, but 2023-01-01 is deprecated and
# predates the named SSE events
# version-enforced 400 without the header and 200 with 2023-06-01
# host https://llm-gw.internal
# (absent) 200 not-enforced 200 with no version header, so something
# on this path is supplying one for you
# 2023-06-01 200 accepted 200, the current version
# 2023-01-01 200 accepted-deprecated 200, but 2023-01-01 is deprecated
# version-not-enforced 200 with no anthropic-version header at all
# gateway-injects the direct host 400s without the header and the gateway
# returns 200, so the gateway adds anthropic-version for you.
# Every client behind it is untested
# repair: set anthropic-version: 2023-06-01 in the client itself. A header the
# gateway adds is a header your client does not have.
# ancient-pinned 2023-01-01 is the initial release and is deprecated
# repair: move the pin to 2023-06-01 and re-read your SSE handling first
# 2 finding(s)
The full code
Three GETs per host and six pure functions. probe_headers, which returns an empty dict for the absent probe and is kept separate from the credential so a test can assert that no version header is sent; probe_labels, which builds the ordered, de-duplicated probe set from the two real versions plus yours; classify_status, which says what one result means in isolation and never more than that; host_verdict, which refuses to grade a matrix whose current-version probe is not a 200; declared_findings, which grades your configured strings against the documented version history rather than against a status code; and gateway_verdict, which is the only function here that looks at two hosts at once.
"""Probe the anthropic-version header three ways, direct and via a gateway.
Read only. Every request is a GET of /v1/models, which lists model metadata,
generates no tokens and bills nothing. Nothing here sends a message, and a 400
from this endpoint costs exactly as little as a 200 -- which is the only reason
a deliberately failing probe is acceptable at all.
Three probes per host: no version header, the current 2023-06-01, and the
2023-01-01 initial release, plus every version string your own clients send,
declared on the command line because nothing in the API can read your source.
No single status is the finding. A required header is only proved required by
two probes that disagree about it, and a header injected or stripped in transit
is only visible by running the same matrix down two paths and diffing them.
"""
import argparse
import logging
import os
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("anthropic_version_header_probe")
API_PATH = "/v1/models"
DIRECT = "https://api.anthropic.com"
# The complete version history. Two entries, and there have never been more.
INITIAL = "2023-01-01"
CURRENT = "2023-06-01"
KNOWN = (INITIAL, CURRENT)
# The label for the probe that deliberately sends no version header. It is not
# a version string and is never sent as one; probe_headers() is where that is
# enforced, and there is a test that says so.
ABSENT = "(absent)"
FINDINGS = ("version-not-enforced", "current-rejected", "ancient-pinned",
"unknown-version-pinned", "gateway-injects", "gateway-strips",
"gateway-disagrees", "unreachable")
REPAIRS = {
"version-not-enforced":
"something on this path adds anthropic-version for you. Find it, then "
"set the header in each client as well: a header the infrastructure "
"supplies is a header your code does not have.",
"gateway-injects":
"set anthropic-version: 2023-06-01 in the client itself. A client that "
"only works behind the gateway is one routing change from a 400 on "
"every request.",
"gateway-strips":
"the gateway is removing or rewriting anthropic-version. Fix it there; "
"a client cannot compensate for a header that does not survive the "
"hop.",
"gateway-disagrees":
"the two paths do not behave the same. Read the gateway's request "
"header policy before trusting either matrix as a description of what "
"your clients send.",
"current-rejected":
"the current version probe did not return 200, so this is a credential "
"or connectivity problem rather than a versioning one. Nothing else in "
"this matrix can be trusted until it is.",
"ancient-pinned":
"move the pin to anthropic-version: 2023-06-01, and read your streaming "
"code first: 2023-06-01 sends incremental named events and no "
"data: [DONE].",
"unknown-version-pinned":
"only 2023-01-01 and 2023-06-01 have ever existed. Replace the string "
"with 2023-06-01 rather than trying to make it work.",
}
def probe_headers(label):
"""The version header for one probe. Pure. Empty dict for ABSENT.
Deliberately separate from the credential. The absent probe has to send no
anthropic-version at all, and a function that merged the auth header in
would make that hard to assert without handling a key in a test.
"""
if label == ABSENT:
return {}
return {"anthropic-version": str(label).strip()}
def probe_labels(declared):
"""The ordered probe set. Pure. ABSENT, the two real versions, then yours.
De-duplicated with order preserved so the printed matrix is stable between
runs, and stripped so a trailing space in an environment variable does not
become a fourth version that has never existed.
"""
out = [ABSENT, CURRENT, INITIAL]
for raw in declared or []:
text = str(raw or "").strip()
if text and text not in out:
out.append(text)
return out
def classify_status(label, status):
"""What one probe result means on its own. Pure. Returns (state, detail).
On its own is the operative phrase. Nothing here is a verdict; the verdicts
need two rows and live in host_verdict() and gateway_verdict().
"""
if status is None:
return ("unreachable", "no response at all from this host")
status = int(status)
if label == ABSENT:
if status == 400:
return ("enforced", "400 with no version header, which is correct")
if status == 200:
return ("not-enforced",
"200 with no version header, so something on this path is "
"supplying one for you")
if status in (401, 403):
return ("credentials",
"%d, so this probe says nothing about the version header"
% status)
return ("unexpected", "%d with no version header" % status)
if status == 200:
if label == CURRENT:
return ("accepted", "200, the current version")
if label == INITIAL:
return ("accepted-deprecated",
"200, but 2023-01-01 is deprecated and predates the named "
"SSE events")
return ("accepted-unknown",
"200 for a string that is not one of the two documented "
"versions")
if status in (401, 403):
return ("credentials",
"%d, which is the credential rather than the version" % status)
if status in (400, 404, 410):
return ("refused",
"%d, this host will not serve that version" % status)
return ("unexpected", "%d" % status)
def host_verdict(results):
"""Grade one host's whole matrix. Pure. Returns (state, detail).
The current-version probe is the gate. If it is not a 200 then the key, the
host or the network is the story and every other row is noise, so this
returns early rather than reporting a header problem it cannot see.
"""
results = dict(results or {})
current = results.get(CURRENT)
absent = results.get(ABSENT)
if current is None:
return ("unreachable",
"the current version probe got no response, so nothing else on "
"this host can be read")
if int(current) in (401, 403):
return ("current-rejected",
"%d for anthropic-version: %s, which is a credential problem "
"and not a versioning one" % (int(current), CURRENT))
if int(current) != 200:
return ("current-rejected",
"%d for anthropic-version: %s, which should be 200"
% (int(current), CURRENT))
if absent is not None and int(absent) == 200:
return ("version-not-enforced",
"200 with no anthropic-version header at all. The header is "
"documented as required, so a proxy, SDK or gateway on this "
"path is adding it")
return ("version-enforced",
"400 without the header and 200 with %s, which is the shape a "
"direct connection should have" % CURRENT)
def declared_findings(results, declared):
"""[(version, state, detail)] for the strings your clients send. Pure.
Graded against the documented version history, not against the status code.
A pin that works today and is deprecated is still a pin that is deprecated,
and a script that only reported non-200s would wave both of these through.
"""
results = dict(results or {})
seen = set()
out = []
for raw in declared or []:
text = str(raw or "").strip()
if not text or text in seen or text == CURRENT:
continue
seen.add(text)
status = results.get(text)
suffix = ("" if status is None
else " (this host returns %d for it)" % int(status))
if text == INITIAL:
out.append((text, "ancient-pinned",
"2023-01-01 is the initial release and is deprecated. A "
"client pinned there does not get the 2023-06-01 SSE "
"format: incremental named events, and no "
"data: [DONE]" + suffix))
else:
out.append((text, "unknown-version-pinned",
"only 2023-01-01 and 2023-06-01 have ever existed, so "
"this string is a typo or an invention" + suffix))
out.sort()
return out
def gateway_verdict(direct, proxy):
"""Compare two hosts' matrices. Pure. Returns (state, detail).
The only function here that looks at two hosts at once, and the only way a
header rewritten in transit becomes visible: from the client it is a 200,
from the API it is a valid request, and nothing but the diff shows it.
"""
direct = dict(direct or {})
proxy = dict(proxy or {})
if not proxy:
return ("no-gateway",
"no gateway base URL was given, so nothing was compared. A "
"header added in transit is invisible to a single host")
d_absent, p_absent = direct.get(ABSENT), proxy.get(ABSENT)
d_current, p_current = direct.get(CURRENT), proxy.get(CURRENT)
if (d_absent is not None and p_absent is not None
and int(d_absent) == 400 and int(p_absent) == 200):
return ("gateway-injects",
"the direct host 400s without the header and the gateway "
"returns 200, so the gateway adds anthropic-version for you. "
"Every client behind it is untested")
if (d_current is not None and p_current is not None
and int(d_current) == 200 and int(p_current) != 200):
return ("gateway-strips",
"anthropic-version: %s is accepted directly and returns %d "
"through the gateway, so it is being stripped or rewritten in "
"transit" % (CURRENT, int(p_current)))
differing = sorted(label for label in set(direct) | set(proxy)
if direct.get(label) != proxy.get(label))
if differing:
return ("gateway-disagrees",
"the two hosts return different statuses for: "
+ ", ".join(differing))
return ("gateway-agrees",
"both hosts return the same status for every probe, so nothing on "
"the way is rewriting the header")
def repair_lines(state):
"""The repair for one verdict. Pure. Printed, never performed."""
line = REPAIRS.get(state)
if not line:
return []
if state in ("gateway-injects", "gateway-strips", "version-not-enforced"):
return [line,
"the durable fix is the official SDK, which sets "
"anthropic-version on every request whether or not anything "
"else does."]
return [line]
def probe(session, base, key, label, timeout=30):
"""One GET. Returns a status code, or None when the host is unreachable.
Never raises on a 4xx: a 400 is the expected answer to one of these probes
and is the most informative result the script can get.
"""
headers = {"x-api-key": key}
headers.update(probe_headers(label))
try:
r = session.get(base.rstrip("/") + API_PATH, headers=headers,
params={"limit": 1}, timeout=timeout)
except requests.RequestException as exc:
log.debug("probe %s against %s failed: %s", label, base, exc)
return None
return r.status_code
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--version", action="append", default=[],
help="a version string your clients send (repeatable)")
ap.add_argument("--gateway", default=os.environ.get("ANTHROPIC_BASE_URL"),
help="base URL of the proxy or gateway to compare against")
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 against %s", API_PATH)
return 2
declared = list(args.version)
declared += [p.strip() for p in
(os.environ.get("ANTHROPIC_VERSIONS") or "").split(",")
if p.strip()]
labels = probe_labels(declared)
hosts = [("direct", DIRECT)]
if args.gateway and args.gateway.rstrip("/") != DIRECT:
hosts.append(("gateway", args.gateway))
session = requests.Session()
matrices = {}
findings = 0
for role, base in hosts:
results = {}
log.info("host %s", base)
for label in labels:
status = probe(session, base, key, label)
results[label] = status
state, detail = classify_status(label, status)
emit = log.warning if state in ("not-enforced", "unreachable") else log.info
emit(" %-13s %s %-20s %s", label,
"---" if status is None else status, state, detail)
matrices[role] = results
state, detail = host_verdict(results)
emit = log.warning if state in FINDINGS else log.info
emit("%-20s %s", state, detail)
for line in repair_lines(state):
emit(" repair: %s", line)
if state in FINDINGS:
findings += 1
state, detail = gateway_verdict(matrices.get("direct"),
matrices.get("gateway"))
emit = log.warning if state in FINDINGS else log.info
emit("%-20s %s", state, detail)
for line in repair_lines(state):
emit(" repair: %s", line)
if state in FINDINGS:
findings += 1
for version, state, detail in declared_findings(matrices.get("direct"),
declared):
log.warning("%-20s %s: %s", state, version, detail)
for line in repair_lines(state):
log.warning(" repair: %s", line)
findings += 1
log.info("%d finding(s)", findings)
return 1 if findings else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Probe the anthropic-version header three ways, direct and via a gateway.
*
* Read only. Every request is a GET of /v1/models, which generates no tokens
* and bills nothing. Nothing here sends a message, and a 400 from this
* endpoint costs exactly as little as a 200.
*
* No single status is the finding. A required header is only proved required
* by two probes that disagree about it, and a header injected or stripped in
* transit is only visible by running the same matrix down two paths.
*/
const API_PATH = '/v1/models';
export const DIRECT = 'https://api.anthropic.com';
export const INITIAL = '2023-01-01';
export const CURRENT = '2023-06-01';
export const KNOWN = [INITIAL, CURRENT];
// The label for the probe that deliberately sends no version header.
export const ABSENT = '(absent)';
const FINDINGS = new Set(['version-not-enforced', 'current-rejected',
'ancient-pinned', 'unknown-version-pinned', 'gateway-injects',
'gateway-strips', 'gateway-disagrees', 'unreachable']);
const REPAIRS = {
'version-not-enforced':
'something on this path adds anthropic-version for you. Find it, then set '
+ 'the header in each client as well: a header the infrastructure supplies '
+ 'is a header your code does not have.',
'gateway-injects':
'set anthropic-version: 2023-06-01 in the client itself. A client that only '
+ 'works behind the gateway is one routing change from a 400 on every '
+ 'request.',
'gateway-strips':
'the gateway is removing or rewriting anthropic-version. Fix it there; a '
+ 'client cannot compensate for a header that does not survive the hop.',
'gateway-disagrees':
'the two paths do not behave the same. Read the gateway request header '
+ 'policy before trusting either matrix as a description of your clients.',
'current-rejected':
'the current version probe did not return 200, so this is a credential or '
+ 'connectivity problem rather than a versioning one. Nothing else in this '
+ 'matrix can be trusted until it is.',
'ancient-pinned':
'move the pin to anthropic-version: 2023-06-01, and read your streaming '
+ 'code first: 2023-06-01 sends incremental named events and no '
+ 'data: [DONE].',
'unknown-version-pinned':
'only 2023-01-01 and 2023-06-01 have ever existed. Replace the string with '
+ '2023-06-01 rather than trying to make it work.',
};
/** The version header for one probe. Pure. Empty object for ABSENT. */
export function probeHeaders(label) {
if (label === ABSENT) return {};
return { 'anthropic-version': String(label).trim() };
}
/** The ordered probe set. Pure. ABSENT, the two real versions, then yours. */
export function probeLabels(declared) {
const out = [ABSENT, CURRENT, INITIAL];
for (const raw of declared ?? []) {
const text = String(raw ?? '').trim();
if (text && !out.includes(text)) out.push(text);
}
return out;
}
/** What one probe result means on its own. Pure. Returns [state, detail]. */
export function classifyStatus(label, status) {
if (status === null || status === undefined) {
return ['unreachable', 'no response at all from this host'];
}
const code = Math.trunc(Number(status));
if (label === ABSENT) {
if (code === 400) return ['enforced', '400 with no version header, which is correct'];
if (code === 200) {
return ['not-enforced',
'200 with no version header, so something on this path is supplying '
+ 'one for you'];
}
if (code === 401 || code === 403) {
return ['credentials',
`${code}, so this probe says nothing about the version header`];
}
return ['unexpected', `${code} with no version header`];
}
if (code === 200) {
if (label === CURRENT) return ['accepted', '200, the current version'];
if (label === INITIAL) {
return ['accepted-deprecated',
'200, but 2023-01-01 is deprecated and predates the named SSE events'];
}
return ['accepted-unknown',
'200 for a string that is not one of the two documented versions'];
}
if (code === 401 || code === 403) {
return ['credentials', `${code}, which is the credential rather than the version`];
}
if (code === 400 || code === 404 || code === 410) {
return ['refused', `${code}, this host will not serve that version`];
}
return ['unexpected', `${code}`];
}
/** Grade one host's whole matrix. Pure. Returns [state, detail]. */
export function hostVerdict(results) {
const r = { ...(results ?? {}) };
const current = r[CURRENT];
const absent = r[ABSENT];
if (current === null || current === undefined) {
return ['unreachable',
'the current version probe got no response, so nothing else on this host '
+ 'can be read'];
}
const code = Math.trunc(Number(current));
if (code === 401 || code === 403) {
return ['current-rejected',
`${code} for anthropic-version: ${CURRENT}, which is a credential problem `
+ 'and not a versioning one'];
}
if (code !== 200) {
return ['current-rejected',
`${code} for anthropic-version: ${CURRENT}, which should be 200`];
}
if (absent !== null && absent !== undefined && Math.trunc(Number(absent)) === 200) {
return ['version-not-enforced',
'200 with no anthropic-version header at all. The header is documented as '
+ 'required, so a proxy, SDK or gateway on this path is adding it'];
}
return ['version-enforced',
`400 without the header and 200 with ${CURRENT}, which is the shape a `
+ 'direct connection should have'];
}
/** [[version, state, detail]] for the strings your clients send. Pure. */
export function declaredFindings(results, declared) {
const r = { ...(results ?? {}) };
const seen = new Set();
const out = [];
for (const raw of declared ?? []) {
const text = String(raw ?? '').trim();
if (!text || seen.has(text) || text === CURRENT) continue;
seen.add(text);
const status = r[text];
const suffix = (status === null || status === undefined)
? '' : ` (this host returns ${Math.trunc(Number(status))} for it)`;
if (text === INITIAL) {
out.push([text, 'ancient-pinned',
'2023-01-01 is the initial release and is deprecated. A client pinned '
+ 'there does not get the 2023-06-01 SSE format: incremental named '
+ 'events, and no data: [DONE]' + suffix]);
} else {
out.push([text, 'unknown-version-pinned',
'only 2023-01-01 and 2023-06-01 have ever existed, so this string is a '
+ 'typo or an invention' + suffix]);
}
}
out.sort((a, b) => a[0].localeCompare(b[0]));
return out;
}
/** Compare two hosts' matrices. Pure. Returns [state, detail]. */
export function gatewayVerdict(direct, proxy) {
const d = { ...(direct ?? {}) };
const p = { ...(proxy ?? {}) };
if (Object.keys(p).length === 0) {
return ['no-gateway',
'no gateway base URL was given, so nothing was compared. A header added '
+ 'in transit is invisible to a single host'];
}
const num = (v) => ((v === null || v === undefined) ? null : Math.trunc(Number(v)));
const dAbsent = num(d[ABSENT]);
const pAbsent = num(p[ABSENT]);
const dCurrent = num(d[CURRENT]);
const pCurrent = num(p[CURRENT]);
if (dAbsent === 400 && pAbsent === 200) {
return ['gateway-injects',
'the direct host 400s without the header and the gateway returns 200, so '
+ 'the gateway adds anthropic-version for you. Every client behind it is '
+ 'untested'];
}
if (dCurrent === 200 && pCurrent !== null && pCurrent !== 200) {
return ['gateway-strips',
`anthropic-version: ${CURRENT} is accepted directly and returns ${pCurrent} `
+ 'through the gateway, so it is being stripped or rewritten in transit'];
}
const labels = [...new Set([...Object.keys(d), ...Object.keys(p)])].sort();
const differing = labels.filter((l) => (d[l] ?? null) !== (p[l] ?? null));
if (differing.length) {
return ['gateway-disagrees',
'the two hosts return different statuses for: ' + differing.join(', ')];
}
return ['gateway-agrees',
'both hosts return the same status for every probe, so nothing on the way '
+ 'is rewriting the header'];
}
/** The repair for one verdict. Pure. Printed, never performed. */
export function repairLines(state) {
const line = REPAIRS[state];
if (!line) return [];
if (state === 'gateway-injects' || state === 'gateway-strips'
|| state === 'version-not-enforced') {
return [line,
'the durable fix is the official SDK, which sets anthropic-version on '
+ 'every request whether or not anything else does.'];
}
return [line];
}
async function probe(base, key, label) {
const url = new URL(base.replace(/\/+$/, '') + API_PATH);
url.searchParams.set('limit', '1');
try {
const r = await fetch(url, { headers: { 'x-api-key': key, ...probeHeaders(label) } });
return r.status;
} catch {
return 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 against /v1/models');
process.exitCode = 2;
return;
}
const declared = (process.env.ANTHROPIC_VERSIONS ?? '')
.split(',').map((s) => s.trim()).filter(Boolean);
const labels = probeLabels(declared);
const gateway = process.env.ANTHROPIC_BASE_URL;
const hosts = [['direct', DIRECT]];
if (gateway && gateway.replace(/\/+$/, '') !== DIRECT) hosts.push(['gateway', gateway]);
const matrices = {};
let findings = 0;
for (const [role, base] of hosts) {
const results = {};
console.log(`host ${base}`);
for (const label of labels) {
const status = await probe(base, key, label);
results[label] = status;
const [state, detail] = classifyStatus(label, status);
console.log(` ${label.padEnd(13)} ${status ?? '---'} ${state.padEnd(20)} ${detail}`);
}
matrices[role] = results;
const [state, detail] = hostVerdict(results);
console.log(`${state.padEnd(20)} ${detail}`);
for (const line of repairLines(state)) console.log(` repair: ${line}`);
if (FINDINGS.has(state)) findings += 1;
}
const [gstate, gdetail] = gatewayVerdict(matrices.direct, matrices.gateway);
console.log(`${gstate.padEnd(20)} ${gdetail}`);
for (const line of repairLines(gstate)) console.log(` repair: ${line}`);
if (FINDINGS.has(gstate)) findings += 1;
for (const [version, state, detail] of declaredFindings(matrices.direct, declared)) {
console.log(`${state.padEnd(20)} ${version}: ${detail}`);
for (const line of repairLines(state)) console.log(` repair: ${line}`);
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 whole argument: a matrix where the absent probe 400s and the current one 200s is healthy, and the same matrix with the absent probe returning 200 is not — the difference is one number and it is the only thing that proves the header is enforced on that path. Then the gateway pair, injecting and stripping, which no single host can see. Then the gate: a 401 on the current-version probe must produce current-rejected and stop, because a matrix you cannot authenticate is not evidence about a header. Then probe_headers, asserted to send nothing at all for the absent label, since a bug there would quietly turn the entire script into three identical probes. And finally the declared strings, graded against a version history with two entries in it rather than against a status code.
from anthropic_version_header_probe import (ABSENT, CURRENT, INITIAL,
classify_status,
declared_findings,
gateway_verdict, host_verdict,
probe_headers, probe_labels,
repair_lines)
def matrix(absent=400, current=200, ancient=200, **extra):
out = {ABSENT: absent, CURRENT: current, INITIAL: ancient}
out.update(extra)
return out
def test_the_pair_of_probes_is_what_proves_the_header_is_required():
# One status code says nothing. Absent-400 next to current-200 is the whole
# claim, and flipping the absent probe to 200 inverts the verdict.
state, detail = host_verdict(matrix())
assert state == "version-enforced"
assert "400 without the header" in detail
state, detail = host_verdict(matrix(absent=200))
assert state == "version-not-enforced"
assert "gateway on this path is adding it" in detail
assert classify_status(ABSENT, 200)[1].endswith("supplying one for you")
assert any("does not have" in line for line in repair_lines(state))
def test_a_gateway_that_injects_the_header_is_only_visible_from_two_hosts():
state, detail = gateway_verdict(matrix(absent=400), matrix(absent=200))
assert state == "gateway-injects"
assert "Every client behind it is untested" in detail
lines = repair_lines(state)
assert any("in the client itself" in line for line in lines)
assert any("official SDK" in line for line in lines)
def test_a_gateway_that_strips_the_header_is_the_mirror_case():
state, detail = gateway_verdict(matrix(), matrix(current=400))
assert state == "gateway-strips"
assert "stripped or rewritten in transit" in detail
# Same statuses on both paths is not a finding, and a missing gateway is a
# statement about coverage rather than about health.
assert gateway_verdict(matrix(), matrix())[0] == "gateway-agrees"
state, detail = gateway_verdict(matrix(), {})
assert state == "no-gateway"
assert "invisible to a single host" in detail
def test_a_matrix_you_cannot_authenticate_is_not_evidence_about_a_header():
# The gate. A 401 on the current-version probe means the key is the story,
# and grading the absent probe on top of it would invent a header problem.
state, detail = host_verdict(matrix(absent=401, current=401))
assert state == "current-rejected"
assert "credential problem" in detail
assert classify_status(ABSENT, 401)[0] == "credentials"
assert host_verdict(matrix(current=None))[0] == "unreachable"
assert host_verdict({})[0] == "unreachable"
def test_the_absent_probe_really_sends_no_version_header():
# A bug here turns the whole script into three identical probes that all
# pass, so it is asserted rather than assumed.
assert probe_headers(ABSENT) == {}
assert probe_headers(CURRENT) == {"anthropic-version": "2023-06-01"}
assert probe_headers(" 2023-01-01 ") == {"anthropic-version": "2023-01-01"}
assert probe_labels([]) == [ABSENT, CURRENT, INITIAL]
assert probe_labels(["2023-06-01", " ", "2024-06-01", "2024-06-01"]) == [
ABSENT, CURRENT, INITIAL, "2024-06-01"]
def test_declared_versions_are_graded_against_the_history_not_the_status():
rows = declared_findings(matrix(**{"2024-06-01": 400}),
[CURRENT, INITIAL, "2024-06-01"])
states = {version: state for version, state, _ in rows}
assert CURRENT not in states
assert states[INITIAL] == "ancient-pinned"
assert states["2024-06-01"] == "unknown-version-pinned"
ancient = [d for v, _, d in rows if v == INITIAL][0]
assert "data: [DONE]" in ancient
assert "this host returns 200 for it" in ancient
assert any("2023-06-01" in line for line in repair_lines("ancient-pinned"))
def test_single_statuses_are_described_and_never_promoted_to_verdicts():
assert classify_status(INITIAL, 200)[0] == "accepted-deprecated"
assert classify_status(INITIAL, 410)[0] == "refused"
assert classify_status("2024-06-01", 200)[0] == "accepted-unknown"
assert classify_status(CURRENT, 529)[0] == "unexpected"
assert classify_status(CURRENT, None)[0] == "unreachable"
assert repair_lines("version-enforced") == []
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { ABSENT, CURRENT, INITIAL, classifyStatus, declaredFindings,
gatewayVerdict, hostVerdict, probeHeaders, probeLabels,
repairLines } from './anthropic-version-header-probe.mjs';
const matrix = ({ absent = 400, current = 200, ancient = 200, ...extra } = {}) =>
({ [ABSENT]: absent, [CURRENT]: current, [INITIAL]: ancient, ...extra });
test('the pair of probes is what proves the header is required', () => {
let [state, detail] = hostVerdict(matrix());
assert.equal(state, 'version-enforced');
assert.ok(detail.includes('400 without the header'));
[state, detail] = hostVerdict(matrix({ absent: 200 }));
assert.equal(state, 'version-not-enforced');
assert.ok(detail.includes('gateway on this path is adding it'));
assert.ok(classifyStatus(ABSENT, 200)[1].endsWith('supplying one for you'));
assert.ok(repairLines(state).some((l) => l.includes('does not have')));
});
test('a gateway that injects the header is only visible from two hosts', () => {
const [state, detail] = gatewayVerdict(matrix({ absent: 400 }), matrix({ absent: 200 }));
assert.equal(state, 'gateway-injects');
assert.ok(detail.includes('Every client behind it is untested'));
const lines = repairLines(state);
assert.ok(lines.some((l) => l.includes('in the client itself')));
assert.ok(lines.some((l) => l.includes('official SDK')));
});
test('a gateway that strips the header is the mirror case', () => {
const [state, detail] = gatewayVerdict(matrix(), matrix({ current: 400 }));
assert.equal(state, 'gateway-strips');
assert.ok(detail.includes('stripped or rewritten in transit'));
assert.equal(gatewayVerdict(matrix(), matrix())[0], 'gateway-agrees');
const [nostate, nodetail] = gatewayVerdict(matrix(), {});
assert.equal(nostate, 'no-gateway');
assert.ok(nodetail.includes('invisible to a single host'));
});
test('a matrix you cannot authenticate is not evidence about a header', () => {
const [state, detail] = hostVerdict(matrix({ absent: 401, current: 401 }));
assert.equal(state, 'current-rejected');
assert.ok(detail.includes('credential problem'));
assert.equal(classifyStatus(ABSENT, 401)[0], 'credentials');
assert.equal(hostVerdict(matrix({ current: null }))[0], 'unreachable');
assert.equal(hostVerdict({})[0], 'unreachable');
});
test('the absent probe really sends no version header', () => {
assert.deepEqual(probeHeaders(ABSENT), {});
assert.deepEqual(probeHeaders(CURRENT), { 'anthropic-version': '2023-06-01' });
assert.deepEqual(probeHeaders(' 2023-01-01 '), { 'anthropic-version': '2023-01-01' });
assert.deepEqual(probeLabels([]), [ABSENT, CURRENT, INITIAL]);
assert.deepEqual(probeLabels(['2023-06-01', ' ', '2024-06-01', '2024-06-01']),
[ABSENT, CURRENT, INITIAL, '2024-06-01']);
});
test('declared versions are graded against the history not the status', () => {
const rows = declaredFindings(matrix({ '2024-06-01': 400 }),
[CURRENT, INITIAL, '2024-06-01']);
const states = Object.fromEntries(rows.map(([v, s]) => [v, s]));
assert.equal(states[CURRENT], undefined);
assert.equal(states[INITIAL], 'ancient-pinned');
assert.equal(states['2024-06-01'], 'unknown-version-pinned');
const ancient = rows.find(([v]) => v === INITIAL)[2];
assert.ok(ancient.includes('data: [DONE]'));
assert.ok(ancient.includes('this host returns 200 for it'));
assert.ok(repairLines('ancient-pinned').some((l) => l.includes('2023-06-01')));
});
test('single statuses are described and never promoted to verdicts', () => {
assert.equal(classifyStatus(INITIAL, 200)[0], 'accepted-deprecated');
assert.equal(classifyStatus(INITIAL, 410)[0], 'refused');
assert.equal(classifyStatus('2024-06-01', 200)[0], 'accepted-unknown');
assert.equal(classifyStatus(CURRENT, 529)[0], 'unexpected');
assert.equal(classifyStatus(CURRENT, null)[0], 'unreachable');
assert.deepEqual(repairLines('version-enforced'), []);
});
FAQ
Which values of anthropic-version are valid?
Two, and there have only ever been two. 2023-01-01 was the initial release and 2023-06-01 is current. 2023-06-01 changed the streaming format to incremental named server-sent events, removed the data: [DONE] event, and dropped the legacy exception and truncated response values. Anything else is a typo or an invention, and the script grades a declared string against that history rather than against whatever a host happens to return for it today.
The header is required, so why does a request without it sometimes work?
Because something between your client and the API is adding it. Gateways, service meshes and API management layers inject default headers as a matter of routine, and adding a sensible anthropic-version to outbound Anthropic traffic is exactly the sort of thing they are configured to do. That is the case this note exists for: the client is broken the whole time and only one of your two network paths says so. It is also why the script runs the same three probes through your gateway base URL and diffs the two matrices, since neither host can see it alone.
Is this the same as the note about an obsolete beta header?
No. That note is about the 1M context window, it reads max_input_tokens off the model object, and it deliberately does not probe anything, because a 200 from GET /v1/models with a beta header proves the name is recognised and says nothing about what the beta does on /v1/messages. It is right to refuse. This note is the opposite situation: anthropic-version is not a feature flag, it is a required header, and a probe that returns 400 without it and 200 with it is a direct measurement of the thing the note is about.
Why probe GET /v1/models rather than an endpoint I actually use?
Because it is free in every sense that matters. The models listing generates no tokens, bills nothing and changes nothing, so both the 200 and the deliberate 400 cost the same as each other, which is nothing. Sending a message to test a header would be a generation, and a script in this section that spends money to find out whether a header is set has misunderstood the assignment.
Can this tell me which of my services is missing the header?
Not on its own, and the script does not pretend otherwise. Neither API exposes the headers of requests you have already made, so there is no endpoint that can name the offending client. What the script gives you is the shape of the fault plus the paths it applies to: if the absent probe returns 200 through your gateway and 400 directly, every service behind that gateway is untested and the search is a grep of your own tree for hand-rolled HTTP. That is a much smaller search than the one you started with.
Related field notes
- The other header, sorted by status code and then by response shape
- A beta header that is valid, accepted, and does nothing
- What else one probe of the model list tells you
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.
- API versions — Claude API reference
- API overview, including the required headers table
- List Models — Claude API reference
- 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.