Skip to content

Diagnostic LLM APIs

A 32 MB request is rejected with 413 before Anthropic sees it

The PDF is 24 MB, which everybody agreed was fine, because the context window is 200,000 tokens and a 24 MB PDF is nothing like 200,000 tokens. The request comes back 413 anyway, with a body that does not look like Anthropic's usual error envelope, and nothing about it appears in the usage report. It never reached Anthropic. It was refused by a proxy in front of the API, for a reason that has nothing to do with tokens.

Read-only key Python and Node.js Tests included
A smiling woman wearing a headset at a computer.
Photo by BaljkanN 4 on Unsplash
The short answer

This ceiling is in bytes. The Messages API and the Token Counting API both cap a request at 32 MB; the Batch API allows 256 MB and the Files API 500 MB. Serialize the body your client actually sends and measure its length. No token count is involved anywhere in this check.

Base64 is what closes the gap. Encoding inflates a payload by about a third, so a 24 MB file becomes 32 MB of JSON string before the envelope is added. That is the arithmetic behind almost every 413 anyone hits.

Confirm it for free: POST /v1/messages/count_tokens shares the same 32 MB ceiling and costs nothing, so posting the identical body there returns 413 on exactly the bodies message creation would reject. Read its status code, not its token number.

The problem in plain words

A 413 from the Claude API is unlike the other errors in the platform, because Anthropic did not produce it. Cloudflare sits in front of the API and refuses oversized requests before they are routed, so the rejection happens outside the application. That has three consequences and all of them are confusing at three in the morning.

The error body may not be Anthropic's JSON envelope, so an SDK that expects {"type": "error", "error": {...}} can fail to parse the failure and raise something unhelpful about the response instead. The request appears in no usage report, because nothing was ever counted. And the size that matters is the size on the wire, which nobody in the room has ever looked at — the team knows the page count, the token estimate and the file size in the bucket, and none of those three is the number Cloudflare measured.

Meanwhile the check people write is the wrong check. They compare the document against the context window, find 40,000 tokens against 200,000, and conclude there is room. There is room. The request is still refused, because a request can be far under the token ceiling and far over the byte one, and those two limits do not know about each other.

24 MB PDFattachedwell under thewindowBase64 adds athird32 MB on the wireCloudflarerefuses it413request_too_largeUsage report isemptyno model wasinvokedTeam shortensthe promptwrong dimensionentirely
Nothing was invoked, so nothing was counted. The error body was written by a proxy and does not match the usual envelope.

Why it happens

Base64 costs you a third, exactly and predictably. Three raw bytes become four ASCII characters, so the encoded string is 4/3 the size of the file. A 24 MiB PDF encodes to precisely 32 MiB before you have added a single key of JSON around it. Anything you were planning to attach inline has to be under about 24 MB raw, and that number is the one worth writing down.

The content ceiling is a third, independent limit. One request may include up to 600 images or PDF pages, and only 100 on the 200k-context models. A 300-page scanned document can be comfortably under 32 MB, comfortably under the window, and still refused for the page count alone. Three ceilings, three units, one request.

Your JSON encoder may be inflating the payload after you measure it. An encoder configured to escape non-ASCII turns one three-byte character into six ASCII ones. On a payload that is mostly Japanese, Arabic or emoji that is close to a doubling, and it happens between the size you measured and the bytes on the wire. Measuring the object rather than the serialized string is how a payload passes your check and fails Cloudflare's.

A newline inside the base64 string is its own rejection. Inline base64 must be unbroken; a library that wraps encoded output at 76 characters, which several still do by default, produces a string the API will not accept. That is a validation failure rather than a size one, and it is worth reporting separately so nobody spends an afternoon shrinking a file that was never too big.

The free probe is a status code, not a number. The counting endpoint shares the 32 MB limit, so it 413s on exactly the bodies message creation would 413 on, at no cost. It also returns an input_tokens number, and this script deliberately ignores it. That number answers a different question with a different ceiling and a different repair.

The fix, as a flow

The only ceiling in the set that is not measured in tokens. Base64 adds a third on the way in, so a 24 MB file lands on the 32 MB line exactly, and the rejection happens in a proxy in front of the API rather than inside it. The fix measures the serialized string and then confirms it for nothing, by reading a status code and ignoring the token number that comes with it.

Serialized bytesplus blobs and blocksOver 32 MB on the wirethe Files API, not a splitOver the image or page capsize was never the issueBase64 carries line breaksa validation failureUnder every ceilingand count_tokens agrees
Three ceilings, three units, one rejection. A payload can pass two of them and be refused by the third.

How to fix it

Serialize the body, then measure the string

Not the file, not the object, not the sum of the parts: the exact JSON your HTTP client will send, encoded as UTF-8, measured in bytes. Compact separators, and the same non-ASCII escaping setting your client uses. This is the only measurement Cloudflare agrees with.

Compare against the ceiling for the endpoint you are calling

32 MB for Messages and for Token Counting, 256 MB for the Batch API, 500 MB for the Files API. The batch case has a second ceiling to check at the same time: 100,000 requests per batch, and the sum of every serialized params block against 256 MB.

Count the images and pages separately

Read max_input_tokens from GET /v1/models/{id} and use it to pick the content cap: 100 on a 200k-context model, 600 on the larger ones. Then count the image and document blocks. This ceiling is unrelated to the other two and fails with its own error.

Probe for free with the counting endpoint

Post the identical body to POST /v1/messages/count_tokens. It is free, it generates nothing, it creates nothing, and it enforces the same 32 MB limit. A 413 back is proof; a 200 back means you are inside the byte ceiling. Read the status, ignore the token count, and remember this is an oracle for the 32 MB endpoints only — a 200 MB batch body will 413 here and be perfectly legal where it is going.

Print the repair: the Files API, or a split

The fix for a large attachment is almost always the Files API: upload once at up to 500 MB, then reference it by file_id on every subsequent request, which removes the bytes from the request entirely and stops you re-uploading the same document on every turn. The fix for too many pages is a split. Neither is something an audit should do on your behalf, so the script prints them.

How to check it worked

Re-run against the same payload after moving the attachment to a file_id. The serialized body should collapse to a few kilobytes.

python3 anthropic_request_bytes.py --payload invoice-batch.json
# over-byte-ceiling    invoice-batch.json   34.1 MB of 32.0 MB (107%). Cloudflare rejects ...
#   base64: 1 blob, 25.6 MB raw inflated to 34.1 MB encoded (133%)
#   largest raw file that still fits inline on this endpoint: 24.0 MB
# 1 payload(s) checked, 1 finding(s)

The full code

Bytes throughout. One GET for the model object, so the per-request content cap is read rather than guessed, and one optional free count_tokens call used purely as a 413 oracle — the script never reads the token number it returns, because that is the other note's ceiling. Nine pure functions and not one of them touches a tokenizer: the serializer that measures what goes on the wire, the base64 size arithmetic in both directions, the inline budget that tells you the largest raw file that still fits, the escaping penalty a JSON encoder can add after you measure, the content cap that depends on the model's window, the newline check, and the verdicts.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 97 LLM API fixes, free and open source.
anthropic_request_bytes.py
"""Measure a Claude request in bytes against the 32 MB ceiling.

Read only. One GET for the model object, and one optional call to
/v1/messages/count_tokens, which is free, creates no object, generates no
completion and is not billed. That call is used here only as an oracle: it
shares the same 32 MB ceiling, so its status code tells you whether message
creation would refuse the same body, at no cost. Its input_tokens number is
deliberately never read, because this script is about bytes.

/v1/messages is never called and nothing is uploaded. The repair is printed.
"""
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_request_bytes")

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

MB = 1024 * 1024

# Per endpoint, in bytes. Binary megabytes: if a payload lands within a percent
# of one of these lines, treat it as over rather than arguing about whether the
# published number meant 1000 or 1024, because the margin is not worth an
# outage.
CEILINGS = {
    "messages": 32 * MB,
    "count_tokens": 32 * MB,
    "batches": 256 * MB,
    "files": 500 * MB,
}

# Sampling parameters the counting endpoint rejects. Stripped only for the
# probe; the measurement is always taken on the real body.
SAMPLING_ONLY = ("max_tokens", "stream", "temperature", "top_p", "top_k",
                 "stop_sequences", "metadata", "service_tier")

NEWLINES = ("\n", "\r")

FINDINGS = ("over-byte-ceiling", "near-byte-ceiling", "over-content-cap",
            "base64-has-newlines")


def serialized_bytes(body, escape_non_ascii=False):
    """The size of the JSON that actually goes on the wire. Pure.

    Measuring the object rather than the string is the mistake this exists to
    stop: a payload can be well inside the ceiling as a dict and outside it as
    the bytes a client sends, which is the only size the proxy in front of the
    API ever sees.
    """
    text = json.dumps(body, separators=(",", ":"), ensure_ascii=escape_non_ascii)
    return len(text.encode("utf-8"))


def human(size):
    """Bytes as a short readable string. Pure. Binary units throughout."""
    n = float(size or 0)
    if n < 1024:
        return "%d B" % int(n)
    if n < MB:
        return "%.1f KB" % (n / 1024.0)
    return "%.1f MB" % (n / float(MB))


def b64_encoded_size(raw_bytes):
    """How large a file becomes once base64 encoded. Pure.

    Three bytes in, four characters out, rounded up to the padding boundary.
    Exactly a third larger, which is why a 24 MiB file lands on precisely the
    32 MiB line before a single key of JSON is wrapped around it.
    """
    raw = max(0, int(raw_bytes or 0))
    return ((raw + 2) // 3) * 4


def b64_decoded_size(text):
    """The raw size behind a base64 string, without decoding it. Pure.

    Decoding a 32 MB string to find out how big the original was allocates 24 MB
    to answer a question arithmetic answers for free.
    """
    clean = "".join(str(text or "").split())
    if not clean:
        return 0
    return (len(clean) // 4) * 3 - clean.count("=")


def inline_budget(ceiling, envelope=0):
    """The largest raw file that still fits inline under `ceiling`. Pure.

    The number worth writing on the ticket. Everything above it has to go
    through the Files API whatever anybody hoped.
    """
    room = max(0, int(ceiling or 0) - max(0, int(envelope or 0)))
    return (room // 4) * 3


def content_blocks(body):
    """Every content block in a Messages body, flattened. Pure."""
    out = []
    if not isinstance(body, dict):
        return out
    system = body.get("system")
    if isinstance(system, list):
        out.extend(b for b in system if isinstance(b, dict))
    for message in body.get("messages") or []:
        if not isinstance(message, dict):
            continue
        content = message.get("content")
        if isinstance(content, list):
            out.extend(b for b in content if isinstance(b, dict))
    return out


def content_units(body):
    """Images and documents in one request. Pure.

    Counted against a ceiling that has nothing to do with bytes or tokens: a
    request may carry a limited number of images and PDF pages whatever its
    size, and a scanned document can pass both other checks and fail this one.
    """
    return sum(1 for b in content_blocks(body)
               if b.get("type") in ("image", "document"))


def base64_blobs(body):
    """Every inline base64 attachment, sized. Pure."""
    out = []
    for block in content_blocks(body):
        source = block.get("source")
        if not isinstance(source, dict) or source.get("type") != "base64":
            continue
        data = source.get("data")
        if not isinstance(data, str):
            continue
        out.append({
            "block": block.get("type"),
            "media_type": source.get("media_type"),
            "encoded": len(data.encode("utf-8")),
            "raw": b64_decoded_size(data),
            "newlines": any(ch in data for ch in NEWLINES),
        })
    return out


def escaping_penalty(body):
    """How much larger the body gets if the client escapes non-ASCII. Pure.

    A JSON encoder writing backslash-u escapes turns one three-byte character
    into six ASCII ones. On a payload that is mostly CJK or emoji that is close
    to a doubling, and it happens after you measured and before the request
    leaves.
    """
    plain = serialized_bytes(body, escape_non_ascii=False)
    if plain <= 0:
        return 1.0
    return serialized_bytes(body, escape_non_ascii=True) / float(plain)


def content_cap(window):
    """Images and PDF pages allowed in one request. Pure. None if unknown.

    Read off the model's context window because the two move together: 100 on
    the 200k-context models, 600 on the larger ones. This is still not a token
    check. The window is being used here only to pick which content cap applies.
    """
    if not isinstance(window, int) or window <= 0:
        return None
    return 100 if window <= 200_000 else 600


def size_verdict(endpoint, size, near=0.8):
    """Classify one serialized body against one endpoint ceiling. Pure."""
    ceiling = CEILINGS.get(endpoint)
    if ceiling is None:
        return ("endpoint-unknown",
                "no published byte ceiling for %r, so there is nothing to "
                "compare %s against" % (endpoint, human(size)))
    shape = "%s of %s (%.0f%%)" % (human(size), human(ceiling),
                                   size / float(ceiling) * 100)
    if size > ceiling:
        return ("over-byte-ceiling",
                "%s. Cloudflare refuses this in front of the API with 413 "
                "request_too_large, so it never reaches Anthropic and never "
                "appears in any usage report." % shape)
    if size >= ceiling * near:
        return ("near-byte-ceiling",
                "%s. Base64 costs a third on the way in, so one more "
                "attachment crosses the line." % shape)
    return ("fits", "%s." % shape)


def content_verdict(units, cap):
    """Classify the image and page count against the per request cap. Pure."""
    if cap is None:
        return ("content-cap-unknown",
                "%d image or document block(s), and no window on the model "
                "object to size the per request cap from" % units)
    if units > cap:
        return ("over-content-cap",
                "%d image or document block(s) against a cap of %d for this "
                "model, which is refused whatever the payload weighs"
                % (units, cap))
    return ("content-fits", "%d image or document block(s) of a %d cap"
            % (units, cap))


def probe_state(status):
    """What the free counting endpoint's status code proves. Pure.

    Status only. The body carries a token count and this script does not read
    it: that number belongs to the context window ceiling, which is a separate
    limit with a separate repair.
    """
    if status == 413:
        return ("confirmed-413",
                "the counting endpoint refused this body at the same 32 MB "
                "ceiling, so message creation refuses it too")
    if status == 200:
        return ("under-byte-ceiling",
                "the counting endpoint accepted the body, so it is inside the "
                "32 MB ceiling for the endpoints that share it")
    return ("probe-inconclusive",
            "the counting endpoint answered %s, which is neither the 413 nor "
            "the 200 this probe reads" % status)


def get(session, path):
    r = session.get(API + path, timeout=30)
    if r.status_code in (401, 403):
        raise SystemExit("%d from Anthropic: ANTHROPIC_API_KEY has to be a "
                         "workspace key" % r.status_code)
    r.raise_for_status()
    return r.json()


def probe(session, body):
    """The one non-GET call, and it neither creates nor bills anything.

    The trimmed body is a few dozen bytes smaller than the one you will send.
    That matters only if you are within a few dozen bytes of 32 MB, and if you
    are, you are over.
    """
    trimmed = {k: v for k, v in (body or {}).items() if k not in SAMPLING_ONLY}
    r = session.post(API + "/messages/count_tokens", json=trimmed, timeout=120)
    return r.status_code


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--payload", action="append", default=[], required=True,
                    metavar="FILE", help="a JSON file holding a real request body")
    ap.add_argument("--endpoint", default="messages",
                    choices=sorted(CEILINGS), help="which ceiling applies")
    ap.add_argument("--near", type=float, default=0.8,
                    help="share of the ceiling at which a body that still fits "
                         "is reported anyway (default 0.8)")
    ap.add_argument("--no-probe", action="store_true",
                    help="skip the free count_tokens status check")
    ap.add_argument("--show-all", action="store_true")
    args = ap.parse_args()

    key = os.environ.get("ANTHROPIC_API_KEY")
    if not key:
        log.error("set ANTHROPIC_API_KEY to a workspace key")
        return 2

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

    windows = {}
    checked = 0
    bad = 0

    for path in args.payload:
        with open(path, "r", encoding="utf-8") as fh:
            body = json.load(fh)
        checked += 1

        size = serialized_bytes(body)
        state, detail = size_verdict(args.endpoint, size, args.near)
        line = "%-20s %-30s %s" % (state, path, detail)
        if state in FINDINGS:
            bad += 1
            log.warning(line)
        elif state == "endpoint-unknown":
            log.warning(line)
        elif args.show_all:
            log.info(line)

        blobs = base64_blobs(body)
        if blobs:
            raw = sum(b["raw"] for b in blobs)
            encoded = sum(b["encoded"] for b in blobs)
            log.info("  base64: %d blob(s), %s raw inflated to %s encoded (%.0f%%)",
                     len(blobs), human(raw), human(encoded),
                     encoded / float(raw) * 100 if raw else 0)
        broken = [b for b in blobs if b["newlines"]]
        if broken:
            bad += 1
            log.warning("%-20s %-30s %d inline blob(s) contain line breaks; "
                        "inline base64 has to be unbroken, and several encoders "
                        "still wrap at 76 characters by default",
                        "base64-has-newlines", path, len(broken))

        penalty = escaping_penalty(body)
        if penalty > 1.05:
            log.warning("  a client escaping non-ASCII would send %.0f%% more "
                        "than measured here (%s), which is enough to cross the "
                        "ceiling on its own",
                        (penalty - 1) * 100, human(int(size * penalty)))

        model = str(body.get("model") or "")
        window = None
        if model:
            if model not in windows:
                obj = get(session, "/models/" + model)
                windows[model] = obj.get("max_input_tokens")
            window = windows[model]
        units = content_units(body)
        if units:
            cstate, cdetail = content_verdict(units, content_cap(window))
            if cstate == "over-content-cap":
                bad += 1
                log.warning("%-20s %-30s %s", cstate, path, cdetail)
            elif cstate == "content-cap-unknown":
                log.warning("%-20s %-30s %s", cstate, path, cdetail)
            elif args.show_all:
                log.info("%-20s %-30s %s", cstate, path, cdetail)

        if not args.no_probe:
            pstate, pdetail = probe_state(probe(session, body))
            log.info("  probe: %s, %s", pstate, pdetail)

        if state in ("over-byte-ceiling", "near-byte-ceiling"):
            ceiling = CEILINGS[args.endpoint]
            envelope = size - sum(b["encoded"] for b in blobs)
            log.warning("  largest raw file that still fits inline on this "
                        "endpoint: %s", human(inline_budget(ceiling, envelope)))
            log.warning("  repair: upload the attachment once through the Files "
                        "API (500 MB) and reference it by file_id, which takes "
                        "the bytes out of every request rather than one. Or "
                        "split the request. Printed, not performed.")

    log.info("%d payload(s) checked, %d finding(s)", checked, bad)
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main())
anthropic-request-bytes.mjs
/**
 * Measure a Claude request in bytes against the 32 MB ceiling.
 *
 * Read only. One GET for the model object, and one optional call to
 * /v1/messages/count_tokens, which is free, creates no object, generates no
 * completion and is not billed. It is used purely as an oracle: it shares the
 * same 32 MB ceiling, so its status code answers the byte question at no cost.
 * The token number it returns is deliberately never read.
 *
 * /v1/messages is never called and nothing is uploaded.
 */
import { readFile } from 'node:fs/promises';

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

const MB = 1024 * 1024;

const CEILINGS = {
  messages: 32 * MB,
  count_tokens: 32 * MB,
  batches: 256 * MB,
  files: 500 * MB,
};

const SAMPLING_ONLY = new Set(['max_tokens', 'stream', 'temperature', 'top_p',
  'top_k', 'stop_sequences', 'metadata', 'service_tier']);

const FINDINGS = new Set(['over-byte-ceiling', 'near-byte-ceiling',
  'over-content-cap', 'base64-has-newlines']);

/** The size of the JSON that actually goes on the wire. Pure. */
export function serializedBytes(body, escapeNonAscii = false) {
  let text = JSON.stringify(body);
  if (text === undefined) text = 'null';
  if (escapeNonAscii) {
    text = text.replace(/[\u0080-\uffff]/g, (ch) =>
      '\\u' + ch.charCodeAt(0).toString(16).padStart(4, '0'));
  }
  return Buffer.byteLength(text, 'utf8');
}

/** Bytes as a short readable string. Pure. Binary units throughout. */
export function human(size) {
  const n = Number(size || 0);
  if (n < 1024) return `${Math.trunc(n)} B`;
  if (n < MB) return `${(n / 1024).toFixed(1)} KB`;
  return `${(n / MB).toFixed(1)} MB`;
}

/**
 * How large a file becomes once base64 encoded. Pure.
 * Three bytes in, four characters out: exactly a third larger, which is why a
 * 24 MiB file lands on precisely the 32 MiB line.
 */
export function b64EncodedSize(rawBytes) {
  const raw = Math.max(0, Math.trunc(rawBytes || 0));
  return Math.floor((raw + 2) / 3) * 4;
}

/** The raw size behind a base64 string, without decoding it. Pure. */
export function b64DecodedSize(text) {
  const clean = String(text ?? '').replace(/\s+/g, '');
  if (!clean) return 0;
  const pad = (clean.match(/=/g) ?? []).length;
  return Math.floor(clean.length / 4) * 3 - pad;
}

/** The largest raw file that still fits inline under `ceiling`. Pure. */
export function inlineBudget(ceiling, envelope = 0) {
  const room = Math.max(0, Math.trunc(ceiling || 0) - Math.max(0, Math.trunc(envelope || 0)));
  return Math.floor(room / 4) * 3;
}

/** Every content block in a Messages body, flattened. Pure. */
export function contentBlocks(body) {
  const out = [];
  if (!body || typeof body !== 'object') return out;
  if (Array.isArray(body.system)) {
    out.push(...body.system.filter((b) => b && typeof b === 'object'));
  }
  for (const message of body.messages ?? []) {
    if (!message || typeof message !== 'object') continue;
    if (Array.isArray(message.content)) {
      out.push(...message.content.filter((b) => b && typeof b === 'object'));
    }
  }
  return out;
}

/** Images and documents in one request. Pure. A ceiling of its own. */
export function contentUnits(body) {
  return contentBlocks(body).filter((b) => b.type === 'image' || b.type === 'document').length;
}

/** Every inline base64 attachment, sized. Pure. */
export function base64Blobs(body) {
  const out = [];
  for (const block of contentBlocks(body)) {
    const source = block.source;
    if (!source || typeof source !== 'object' || source.type !== 'base64') continue;
    const data = source.data;
    if (typeof data !== 'string') continue;
    out.push({
      block: block.type,
      media_type: source.media_type,
      encoded: Buffer.byteLength(data, 'utf8'),
      raw: b64DecodedSize(data),
      newlines: data.includes('\n') || data.includes('\r'),
    });
  }
  return out;
}

/** How much larger the body gets if the client escapes non-ASCII. Pure. */
export function escapingPenalty(body) {
  const plain = serializedBytes(body, false);
  if (plain <= 0) return 1;
  return serializedBytes(body, true) / plain;
}

/** Images and PDF pages allowed in one request. Pure. null if unknown. */
export function contentCap(window) {
  if (!Number.isInteger(window) || window <= 0) return null;
  return window <= 200000 ? 100 : 600;
}

/** Classify one serialized body against one endpoint ceiling. Pure. */
export function sizeVerdict(endpoint, size, near = 0.8) {
  const ceiling = CEILINGS[endpoint];
  if (ceiling === undefined) {
    return ['endpoint-unknown',
      `no published byte ceiling for '${endpoint}', so there is nothing to ` +
      `compare ${human(size)} against`];
  }
  const shape = `${human(size)} of ${human(ceiling)} (${(size / ceiling * 100).toFixed(0)}%)`;
  if (size > ceiling) {
    return ['over-byte-ceiling',
      `${shape}. Cloudflare refuses this in front of the API with 413 ` +
      'request_too_large, so it never reaches Anthropic and never appears in ' +
      'any usage report.'];
  }
  if (size >= ceiling * near) {
    return ['near-byte-ceiling',
      `${shape}. Base64 costs a third on the way in, so one more attachment ` +
      'crosses the line.'];
  }
  return ['fits', `${shape}.`];
}

/** Classify the image and page count against the per request cap. Pure. */
export function contentVerdict(units, cap) {
  if (cap === null || cap === undefined) {
    return ['content-cap-unknown',
      `${units} image or document block(s), and no window on the model object ` +
      'to size the per request cap from'];
  }
  if (units > cap) {
    return ['over-content-cap',
      `${units} image or document block(s) against a cap of ${cap} for this ` +
      'model, which is refused whatever the payload weighs'];
  }
  return ['content-fits', `${units} image or document block(s) of a ${cap} cap`];
}

/** What the free counting endpoint's status code proves. Pure. Status only. */
export function probeState(status) {
  if (status === 413) {
    return ['confirmed-413',
      'the counting endpoint refused this body at the same 32 MB ceiling, so ' +
      'message creation refuses it too'];
  }
  if (status === 200) {
    return ['under-byte-ceiling',
      'the counting endpoint accepted the body, so it is inside the 32 MB ' +
      'ceiling for the endpoints that share it'];
  }
  return ['probe-inconclusive',
    `the counting endpoint answered ${status}, which is neither the 413 nor ` +
    'the 200 this probe reads'];
}

function headers(key) {
  return { 'x-api-key': key, 'anthropic-version': VERSION,
           'content-type': 'application/json' };
}

async function get(key, path) {
  const res = await fetch(API + path, { headers: headers(key) });
  if (res.status === 401 || res.status === 403) {
    throw new Error(`${res.status} from Anthropic: ANTHROPIC_API_KEY has to be a workspace key`);
  }
  if (!res.ok) throw new Error(`${res.status} from ${path}`);
  return res.json();
}

/** The one non-GET call, and it neither creates nor bills anything. */
async function probe(key, body) {
  const trimmed = Object.fromEntries(
    Object.entries(body ?? {}).filter(([k]) => !SAMPLING_ONLY.has(k)));
  const res = await fetch(`${API}/messages/count_tokens`, {
    method: 'POST',  // count_tokens creates nothing and bills nothing
    headers: headers(key),
    body: JSON.stringify(trimmed),
  });
  return res.status;
}

async function main() {
  const key = process.env.ANTHROPIC_API_KEY;
  if (!key) {
    console.error('set ANTHROPIC_API_KEY to a workspace key');
    process.exitCode = 2;
    return;
  }
  const paths = process.argv.slice(2).filter((a) => !a.startsWith('--'));
  if (paths.length === 0) {
    console.error('pass one or more payload JSON files');
    process.exitCode = 2;
    return;
  }
  const endpoint = process.env.ENDPOINT ?? 'messages';
  const near = Number(process.env.NEAR ?? 0.8);
  const noProbe = process.env.NO_PROBE === '1';
  const showAll = process.env.SHOW_ALL === '1';

  const windows = new Map();
  let checked = 0;
  let bad = 0;

  for (const path of paths) {
    const body = JSON.parse(await readFile(path, 'utf8'));
    checked += 1;

    const size = serializedBytes(body);
    const [state, detail] = sizeVerdict(endpoint, size, near);
    const line = `${state.padEnd(20)} ${path.padEnd(30)} ${detail}`;
    if (FINDINGS.has(state)) { bad += 1; console.warn(line); }
    else if (state === 'endpoint-unknown') console.warn(line);
    else if (showAll) console.log(line);

    const blobs = base64Blobs(body);
    if (blobs.length) {
      const raw = blobs.reduce((s, b) => s + b.raw, 0);
      const encoded = blobs.reduce((s, b) => s + b.encoded, 0);
      console.log(`  base64: ${blobs.length} blob(s), ${human(raw)} raw inflated ` +
                  `to ${human(encoded)} encoded ` +
                  `(${raw ? (encoded / raw * 100).toFixed(0) : 0}%)`);
    }
    const broken = blobs.filter((b) => b.newlines);
    if (broken.length) {
      bad += 1;
      console.warn(`${'base64-has-newlines'.padEnd(20)} ${path.padEnd(30)} ` +
                   `${broken.length} inline blob(s) contain line breaks; inline ` +
                   'base64 has to be unbroken, and several encoders still wrap ' +
                   'at 76 characters by default');
    }

    const penalty = escapingPenalty(body);
    if (penalty > 1.05) {
      console.warn(`  a client escaping non-ASCII would send ` +
                   `${((penalty - 1) * 100).toFixed(0)}% more than measured here ` +
                   `(${human(Math.trunc(size * penalty))}), which is enough to ` +
                   'cross the ceiling on its own');
    }

    const model = String(body.model ?? '');
    let window = null;
    if (model) {
      if (!windows.has(model)) {
        windows.set(model, (await get(key, `/models/${model}`)).max_input_tokens ?? null);
      }
      window = windows.get(model);
    }
    const units = contentUnits(body);
    if (units) {
      const [cstate, cdetail] = contentVerdict(units, contentCap(window));
      const cline = `${cstate.padEnd(20)} ${path.padEnd(30)} ${cdetail}`;
      if (cstate === 'over-content-cap') { bad += 1; console.warn(cline); }
      else if (cstate === 'content-cap-unknown') console.warn(cline);
      else if (showAll) console.log(cline);
    }

    if (!noProbe) {
      const [pstate, pdetail] = probeState(await probe(key, body));
      console.log(`  probe: ${pstate}, ${pdetail}`);
    }

    if (state === 'over-byte-ceiling' || state === 'near-byte-ceiling') {
      const envelope = size - blobs.reduce((s, b) => s + b.encoded, 0);
      console.warn('  largest raw file that still fits inline on this endpoint: ' +
                   human(inlineBudget(CEILINGS[endpoint], envelope)));
      console.warn('  repair: upload the attachment once through the Files API ' +
                   '(500 MB) and reference it by file_id, which takes the bytes ' +
                   'out of every request rather than one. Or split the request. ' +
                   'Printed, not performed.');
    }
  }

  console.log(`${checked} payload(s) checked, ${bad} finding(s)`);
  process.exitCode = bad ? 1 : 0;
}

if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

The first test is the arithmetic the whole note rests on: 24 MiB of raw file base64 encodes to exactly 33,554,432 bytes, which is the 32 MB ceiling to the byte, so the JSON wrapped around it is what pushes the request over. The second is the trap — a payload can be a long way inside the byte ceiling and still be refused for carrying more images than the model allows, and the cap it is refused against depends on the model's window. The rest pin the parts that move between measuring and sending: a JSON encoder that escapes non-ASCII, a base64 library that wraps at 76 characters, and a probe whose status code is the answer while its token count is somebody else's question.

test_anthropic_request_bytes.py
from anthropic_request_bytes import (b64_decoded_size, b64_encoded_size,
                                      base64_blobs, content_cap, content_units,
                                      content_verdict, escaping_penalty, human,
                                      inline_budget, probe_state,
                                      serialized_bytes, size_verdict)

MB = 1024 * 1024


def test_a_24mb_file_lands_exactly_on_the_32mb_line():
    # The arithmetic the note is about. Three bytes become four characters, so
    # 24 MiB encodes to precisely 32 MiB and everything else in the request is
    # what takes it over.
    assert b64_encoded_size(24 * MB) == 32 * MB == 33_554_432
    assert size_verdict("messages", 32 * MB)[0] == "near-byte-ceiling"
    state, detail = size_verdict("messages", 32 * MB + 4_096)
    assert state == "over-byte-ceiling"
    assert "Cloudflare" in detail
    assert "never appears in any usage report" in detail
    # And the number to put on the ticket, once the envelope is accounted for.
    assert inline_budget(32 * MB, 4_096) == 24 * MB - 3_072


def test_the_image_cap_is_a_separate_ceiling_from_the_bytes():
    # 300 pages of tiny scans: nowhere near 32 MB, refused anyway, and the cap
    # depends on the model's window rather than on the payload.
    assert content_cap(200_000) == 100
    assert content_cap(1_000_000) == 600
    assert content_cap(None) is None
    assert content_verdict(300, 100)[0] == "over-content-cap"
    assert content_verdict(300, 600)[0] == "content-fits"
    assert content_verdict(300, None)[0] == "content-cap-unknown"
    assert size_verdict("messages", 2 * MB)[0] == "fits"


def test_the_ceiling_depends_on_the_endpoint_not_on_the_body():
    body_size = 200 * MB
    assert size_verdict("messages", body_size)[0] == "over-byte-ceiling"
    assert size_verdict("batches", body_size)[0] == "fits"
    assert size_verdict("files", body_size)[0] == "fits"
    assert size_verdict("responses", body_size)[0] == "endpoint-unknown"


def test_blobs_are_sized_without_decoding_them():
    data = "QUJDREVGR0g="  # eight raw bytes, twelve encoded characters
    body = {"model": "claude-sonnet-5", "messages": [{"role": "user", "content": [
        {"type": "text", "text": "read this"},
        {"type": "document", "source": {"type": "base64",
                                        "media_type": "application/pdf",
                                        "data": data}},
    ]}]}
    blobs = base64_blobs(body)
    assert len(blobs) == 1
    assert blobs[0]["media_type"] == "application/pdf"
    assert blobs[0]["encoded"] == 12
    assert blobs[0]["raw"] == b64_decoded_size(data) == 8
    assert blobs[0]["newlines"] is False
    assert content_units(body) == 1


def test_line_wrapped_base64_is_its_own_rejection():
    # Not a size problem at all: several encoders wrap at 76 characters by
    # default and the API will not accept the result.
    body = {"messages": [{"role": "user", "content": [
        {"type": "image", "source": {"type": "base64", "media_type": "image/png",
                                     "data": "QUJDREVG\nR0g="}}]}]}
    assert base64_blobs(body)[0]["newlines"] is True
    # And the whitespace is not counted as payload when the size is worked out.
    assert base64_blobs(body)[0]["raw"] == 8


def test_a_client_that_escapes_non_ascii_sends_more_than_you_measured():
    body = {"messages": [{"role": "user", "content": "\u3053\u3093\u306b\u3061\u306f" * 100}]}
    plain = serialized_bytes(body)
    escaped = serialized_bytes(body, escape_non_ascii=True)
    assert escaped > plain
    assert escaping_penalty(body) == escaped / float(plain)
    assert escaping_penalty(body) > 1.9
    # ASCII payloads are unaffected, so this never fires as noise.
    assert escaping_penalty({"messages": [{"role": "user", "content": "hello"}]}) == 1.0


def test_the_probe_is_read_as_a_status_code_not_as_a_token_count():
    assert probe_state(413)[0] == "confirmed-413"
    assert probe_state(200)[0] == "under-byte-ceiling"
    assert probe_state(400)[0] == "probe-inconclusive"
    assert probe_state(429)[0] == "probe-inconclusive"


def test_sizes_are_printed_in_binary_units():
    assert human(0) == "0 B"
    assert human(1023) == "1023 B"
    assert human(1024) == "1.0 KB"
    assert human(32 * MB) == "32.0 MB"
anthropic-request-bytes.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { b64DecodedSize, b64EncodedSize, base64Blobs, contentCap, contentUnits,
         contentVerdict, escapingPenalty, human, inlineBudget, probeState,
         serializedBytes, sizeVerdict } from './anthropic-request-bytes.mjs';

const MB = 1024 * 1024;

test('a 24mb file lands exactly on the 32mb line', () => {
  assert.equal(b64EncodedSize(24 * MB), 32 * MB);
  assert.equal(b64EncodedSize(24 * MB), 33554432);
  assert.equal(sizeVerdict('messages', 32 * MB)[0], 'near-byte-ceiling');
  const [state, detail] = sizeVerdict('messages', 32 * MB + 4096);
  assert.equal(state, 'over-byte-ceiling');
  assert.match(detail, /Cloudflare/);
  assert.match(detail, /never appears in any usage report/);
  assert.equal(inlineBudget(32 * MB, 4096), 24 * MB - 3072);
});

test('the image cap is a separate ceiling from the bytes', () => {
  assert.equal(contentCap(200000), 100);
  assert.equal(contentCap(1000000), 600);
  assert.equal(contentCap(null), null);
  assert.equal(contentVerdict(300, 100)[0], 'over-content-cap');
  assert.equal(contentVerdict(300, 600)[0], 'content-fits');
  assert.equal(contentVerdict(300, null)[0], 'content-cap-unknown');
  assert.equal(sizeVerdict('messages', 2 * MB)[0], 'fits');
});

test('the ceiling depends on the endpoint not on the body', () => {
  const size = 200 * MB;
  assert.equal(sizeVerdict('messages', size)[0], 'over-byte-ceiling');
  assert.equal(sizeVerdict('batches', size)[0], 'fits');
  assert.equal(sizeVerdict('files', size)[0], 'fits');
  assert.equal(sizeVerdict('responses', size)[0], 'endpoint-unknown');
});

test('blobs are sized without decoding them', () => {
  const data = 'QUJDREVGR0g=';  // eight raw bytes, twelve encoded characters
  const body = { model: 'claude-sonnet-5', messages: [{ role: 'user', content: [
    { type: 'text', text: 'read this' },
    { type: 'document', source: { type: 'base64', media_type: 'application/pdf', data } },
  ] }] };
  const blobs = base64Blobs(body);
  assert.equal(blobs.length, 1);
  assert.equal(blobs[0].media_type, 'application/pdf');
  assert.equal(blobs[0].encoded, 12);
  assert.equal(blobs[0].raw, b64DecodedSize(data));
  assert.equal(blobs[0].raw, 8);
  assert.equal(blobs[0].newlines, false);
  assert.equal(contentUnits(body), 1);
});

test('line wrapped base64 is its own rejection', () => {
  const body = { messages: [{ role: 'user', content: [
    { type: 'image', source: { type: 'base64', media_type: 'image/png',
                               data: 'QUJDREVG\nR0g=' } }] }] };
  assert.equal(base64Blobs(body)[0].newlines, true);
  assert.equal(base64Blobs(body)[0].raw, 8);
});

test('a client that escapes non ascii sends more than you measured', () => {
  const body = { messages: [{ role: 'user', content: '\u3053\u3093\u306b\u3061\u306f'.repeat(100) }] };
  const plain = serializedBytes(body);
  const escaped = serializedBytes(body, true);
  assert.ok(escaped > plain);
  assert.equal(escapingPenalty(body), escaped / plain);
  assert.ok(escapingPenalty(body) > 1.9);
  assert.equal(escapingPenalty({ messages: [{ role: 'user', content: 'hello' }] }), 1);
});

test('the probe is read as a status code not as a token count', () => {
  assert.equal(probeState(413)[0], 'confirmed-413');
  assert.equal(probeState(200)[0], 'under-byte-ceiling');
  assert.equal(probeState(400)[0], 'probe-inconclusive');
  assert.equal(probeState(429)[0], 'probe-inconclusive');
});

test('sizes are printed in binary units', () => {
  assert.equal(human(0), '0 B');
  assert.equal(human(1023), '1023 B');
  assert.equal(human(1024), '1.0 KB');
  assert.equal(human(32 * MB), '32.0 MB');
});

FAQ

Why does the 413 not show up in my usage report?

Because nothing was used. On the direct Claude API a request over the byte ceiling is refused by Cloudflare in front of Anthropic's servers, so no model was invoked, no tokens were counted and no line item exists. This also explains the odd error body: what you are reading was written by the proxy, not by the API, which is why an SDK expecting Anthropic's error envelope can fail to parse it.

How big can an inline file actually be?

About 24 MB raw on the Messages API, because base64 makes it a third larger and 24 MiB encodes to exactly 32 MiB before any JSON is wrapped around it. Subtract whatever your system prompt, tools and conversation weigh and the practical number is a little lower. Anything above that has to go through the Files API.

Is the probe safe to run against production?

Yes, and that is the point of using it. The counting endpoint creates no message, generates no output, is not billed, and runs on its own rate limit rather than the message limiter. It shares the 32 MB ceiling, so its status code answers the byte question exactly. It is the one non-GET call in this batch and it changes nothing on your account.

I am under 32 MB and under the context window and still getting rejected.

Check the two ceilings that are neither of those. A request may carry at most 600 images or PDF pages, and only 100 on the 200k-context models, whatever the payload weighs. And inline base64 must be unbroken: an encoder that wraps at 76 characters produces a string the API refuses on validation grounds rather than size grounds.

Does the Batch API make this go away?

It moves the line rather than removing it. A batch submission may be up to 256 MB and up to 100,000 requests, but each individual params block still has to be a legal Messages request, so an oversized document is oversized there too. The Files API is the fix for size; batching is the fix for latency and price.

Related field notes

Sources

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

Stuck on a tricky one?

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