Diagnostic LLM APIs
Tool schemas are most of the input tokens on every call
The agent has forty tools because forty things are worth doing, and each definition is a careful JSON schema with descriptions on every property because that is how you get the arguments right. A support turn is one sentence from a customer. Nobody has ever asked what the block in front of that sentence weighs, and the answer, when somebody finally counts it, is that the machinery is thirteen times the conversation on every single call.
Count the same request twice. POST /v1/messages/count_tokens is free, creates nothing, generates no completion and is not billed — it is the only pre-flight either provider offers. Send the exact body with its tools, then send it again with tools and tool_choice removed. The difference is the per-call tool overhead, exactly, before you spend anything.
Two things live inside that difference and they have different repairs. Your schemas are one. The other is an automatic tool-use system prompt that Claude adds whenever any tools are present at all: 286 tokens on Claude Opus 5 for tool_choice of auto or none and 406 for any or a named tool, 354/474 on Sonnet 5, and larger on several older ids. Ablate one tool at a time and the deltas will not sum to the whole overhead, because no single ablation removes that fixed charge.
Then price it. Overhead tokens multiplied by calls per day, at your model's input rate, is a monthly number, and it is charged at the full uncached rate unless a cache_control breakpoint sits after the tool block.
This is a per-call weight, not a per-minute limiter. If the symptom is 429s rather than an invoice, the question is which limiter emptied, and the input-tokens-per-minute note owns that.
The problem in plain words
Everything in tools is input tokens: names, descriptions, and every line of the JSON schema including the property descriptions that make the arguments come back right. It is prompt, and it is re-sent on every request, because the API is stateless and there is nowhere to leave it. A registry that grew one tool at a time, each addition obviously worth it, arrives at a fixed per-call cost that nobody ever decided on.
The second cost is the one nobody expects, because it is not in your code at all. The moment any tool is present, an automatic tool-use system prompt is added to the request, and it is not small: several hundred tokens, varying by model id and by whether tool_choice forces a call. And the whole block sits first in the cache order — tools, then system, then messages — so editing one description invalidates not just the tools but everything cached behind them.
Why it happens
The measurement is free, exact, and nobody takes it. count_tokens is a real tokenizer pass over the real body, so it is not an estimate and not a character heuristic. It creates no message, generates no output and costs nothing. Two calls give you the number for one request shape; a handful gives you the number for your whole surface. There is no reason to guess and every codebase guesses.
Ablation prices each tool, and the residual is the interesting part. Remove one tool, count again, and the delta is that tool's schema weight. Do it for all of them and the deltas sum to less than the total overhead, because every ablated body still has tools in it and therefore still carries the automatic tool-use system prompt. What is left over is a fixed charge for having any tools at all, and it cannot be optimised by pruning — only by not sending tools on turns that do not need them.
Anthropic's own server tools are priced the same way and are much larger. The bash tool is 325 tokens on Opus 5, 4.8 and 4.7 and 244 on Opus 4.6, Sonnet 4.6 and earlier; the text editor is about 700; the computer toolset is around 4,500 and the browser toolset around 6,600. Enabling one of those is a decision with a per-call price attached, and the price is not on the pricing page next to the model.
The repair is a cache breakpoint before it is a smaller registry. Tool definitions are the most stable part of a prompt and they sit at the very front of the cache order, which makes them the single best thing to put a cache_control breakpoint after. A read costs 0.1x base input. The corollary is uncomfortable and worth saying out loud: once the block is cached, editing a tool description is expensive, because it invalidates the tools, the system prompt and the conversation behind them.
Deferred loading is the other lever and it has a trap in it. The tool search tool lets rarely-used definitions carry defer_loading: true so their schemas are fetched on demand rather than sent every turn. Set it on every tool and the API returns 400 — All tools have defer_loading set — so the function in this script that picks candidates is written to be structurally incapable of returning the whole list. Which tools are rare is not a question this script can answer; the call-coverage note answers it.
The fix, as a flow
The only ceiling either API will state before you spend anything is a token count, and it is free. Count the same body twice, once with the tools block and once without, and the difference is what the schemas cost on every call. Ablate one tool at a time and the deltas sum to less than the whole, because a fixed charge arrives with any tools.
How to fix it
Capture one real request body as JSON
The exact model, system, tools, tool_choice and a representative one-line messages array. A trimmed or idealised body measures a request you do not send. The sampling fields — max_tokens, temperature, stream and the rest — are stripped before counting, because the counting endpoint refuses them.
Count with tools, then count without
Two calls to POST /v1/messages/count_tokens. Free, non-billed, and they generate nothing. The second body has tools and tool_choice removed and is otherwise byte-identical, so the difference is attributable to the tools and to nothing else.
Ablate one tool at a time for a per-tool price
One more free call per tool. Each delta is that tool's schema weight in tokens. Sum them and subtract from the total overhead: what remains is the automatic tool-use system prompt, the fixed charge that no amount of pruning removes.
Turn tokens into a monthly number
Overhead per call, times calls per day, times thirty, at your model's input rate. Do it at the uncached rate first, because that is what you are paying today, and quote the cached figure as the target rather than the baseline.
Print the breakpoint, then the deferral, then the pruning
A cache_control breakpoint after the tool block is the cheapest change and the first one to make. Deferred loading on rarely-used tools is second, and never on all of them. Deleting tools is third, and it needs the coverage data this note does not have.
How to check it worked
Re-run after the breakpoint lands. The counted overhead does not change — it is the same tokens — but the usage report should start showing cache reads against them at a tenth of the rate. Re-run again after any tool edit, because that edit invalidated the block.
python3 anthropic_tool_schema_overhead.py --payload body.json --calls-per-day 10000
# schema-dominates body.json 11500 of 12388 input token(s) are the tools block (93%)
# 888 token(s) of system and messages, so the tools outweigh the conversation 13.0 to 1
# 286 of the overhead is the automatic tool-use system prompt for claude-opus-5 at tool_choice auto
# the fixed charge no ablation removes: 286 token(s); your schemas account for 11214
# heaviest: search_knowledge_base 2140, create_ticket 1880, lookup_order 1210
# 6% of the 200000 token context window is spent before the user says anything
# at 10000 call(s) a day and 3.00 per million input tokens that is 10350.00 a month, uncached
# repair: put a cache_control breakpoint after the tools block. A read costs 0.1x base input.
# 1 payload(s) measured, 1 finding(s)
The full code
One GET for the model object and then nothing but the free counter. Ten pure functions: the field stripper, which removes what the counting endpoint refuses without touching what is being measured; the two body transforms, whole-tools and one-tool-out; the overhead and its share; the classifier; the published tool-use system prompt table, matched on longest model prefix so claude-opus-4-5 is never read as claude-opus-5; the residual, which is the honest way to say that ablation deltas do not add up to the whole; the deferral picker, written so it cannot return every tool and reproduce the 400; the monthly price; and the share of the context window the fixed prefix eats before anyone speaks.
"""Measure what a Claude tools block costs in input tokens on every call.
Read only. One GET for the model object and a handful of calls to
/v1/messages/count_tokens, which is free, creates no object, generates no
completion and is not billed. /v1/messages is never called.
The method is subtraction: count the exact body, count it again with tools
removed, and the difference is the per-call tool overhead. Ablating one tool at
a time prices each schema, and the deltas deliberately do not sum to the whole,
because every ablated body still carries the automatic tool-use system prompt.
The repair is printed, never performed. A cache breakpoint is a deploy.
"""
import argparse
import copy
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_tool_schema_overhead")
API = "https://api.anthropic.com/v1"
VERSION = "2023-06-01"
# Fields the counting endpoint refuses. Stripped from every body before it is
# counted, and stripped identically from all of them so the subtraction stays
# honest: a field removed from one body and not another moves the difference.
SAMPLING_ONLY = ("max_tokens", "stream", "temperature", "top_p", "top_k",
"stop_sequences", "metadata", "service_tier")
# The automatic tool-use system prompt, per model, as (auto_or_none,
# any_or_tool). Added by the API whenever any tool is present, so it is part of
# the overhead and no amount of pruning removes it. Matched on longest prefix:
# a substring test reads claude-opus-4-5 as claude-opus-5 and reports the wrong
# fixed charge with total confidence.
TOOL_SYSTEM_PROMPT = {
"claude-opus-5": (286, 406),
"claude-opus-4-8": (290, 410),
"claude-opus-4-7": (675, 804),
"claude-opus-4-6": (497, 589),
"claude-sonnet-4-6": (497, 589),
"claude-sonnet-5": (354, 474),
"claude-opus-4-5": (496, 588),
"claude-sonnet-4-5": (496, 588),
"claude-haiku-4-5": (496, 588),
}
FINDINGS = ("schema-dominates", "schema-heavy")
def _int(value):
"""Read a token count as an int. Pure. Missing and unreadable both mean 0."""
try:
return int(value or 0)
except (TypeError, ValueError):
return 0
def countable(body):
"""A body the counting endpoint will accept. Pure. Does not mutate.
Only the sampling fields go. Everything being measured stays, because the
number is worthless if the thing counted is not the thing sent.
"""
if not isinstance(body, dict):
return {}
return {k: copy.deepcopy(v) for k, v in body.items() if k not in SAMPLING_ONLY}
def without_tools(body):
"""The same body with the whole tools block removed. Pure.
tool_choice goes with it. A body that names a tool it no longer declares is
rejected, and the rejection would be read as "the counter is broken".
"""
stripped = countable(body)
stripped.pop("tools", None)
stripped.pop("tool_choice", None)
return stripped
def tool_names(body):
"""Named tools in a body, in declaration order. Pure."""
out = []
for tool in (body or {}).get("tools") or []:
if not isinstance(tool, dict):
continue
name = str(tool.get("name") or "").strip()
if name and name not in out:
out.append(name)
return out
def without_tool(body, name):
"""The same body with exactly one tool removed. Pure. Does not mutate."""
stripped = countable(body)
kept = [t for t in stripped.get("tools") or []
if not (isinstance(t, dict) and str(t.get("name") or "") == str(name))]
stripped["tools"] = kept
if not kept:
stripped.pop("tools", None)
stripped.pop("tool_choice", None)
return stripped
def overhead(total, base):
"""Tokens attributable to the tools block. Pure. Never negative."""
return max(0, _int(total) - _int(base))
def overhead_share(total, base):
"""Share of the counted input that the tools block accounts for. Pure.
None when nothing was counted, which is a different state from zero and
must not be printed as 0%.
"""
counted = _int(total)
if counted <= 0:
return None
return overhead(total, base) / float(counted)
def choice_kind(body):
"""Which column of the tool-use system prompt table applies. Pure.
auto and none share one size; any and a named tool share the larger one.
"""
choice = (body or {}).get("tool_choice")
kind = ""
if isinstance(choice, str):
kind = choice.strip().lower()
elif isinstance(choice, dict):
kind = str(choice.get("type") or "").strip().lower()
if kind in ("any", "tool"):
return "any"
return "auto"
def system_prompt_tokens(model, kind="auto"):
"""The automatic tool-use system prompt for one model. Pure. None if unlisted.
Longest prefix wins. Unlisted ids return None rather than a neighbour's
number, because a plausible wrong number here silently corrupts the split
between "your schemas" and "the fixed charge".
"""
name = str(model or "").strip().lower()
best = None
best_len = -1
for prefix, sizes in TOOL_SYSTEM_PROMPT.items():
if (name == prefix or name.startswith(prefix + "-")) and len(prefix) > best_len:
best = sizes
best_len = len(prefix)
if best is None:
return None
return best[1] if str(kind).lower() == "any" else best[0]
def fixed_overhead(total_overhead, per_tool):
"""The part of the tool overhead that belongs to no single tool. Pure.
Ablating one tool never removes the automatic tool-use system prompt,
because the remaining tools still require it. So the per-tool deltas sum to
the schema weight alone and the residual is the fixed charge for having any
tools at all. Printing the sum as if it were the total is the mistake this
function exists to make impossible.
"""
measured = sum(max(0, _int(row.get("tokens"))) for row in per_tool or [])
return max(0, _int(total_overhead) - measured), measured
def classify(total, base, dominate=0.5, heavy=0.25):
"""Classify one measured payload. Pure. Returns (state, detail)."""
counted = _int(total)
if counted <= 0:
return ("nothing-counted",
"the counting endpoint returned no tokens for this body")
weight = overhead(total, base)
if weight <= 0:
return ("no-tools",
"%d input token(s) and no measurable tools block" % counted)
share = weight / float(counted)
rest = counted - weight
shape = ("%d of %d input token(s) are the tools block (%.0f%%)"
% (weight, counted, share * 100))
if rest > 0:
shape += (", against %d token(s) of system and messages, a ratio of "
"%.1f to 1" % (rest, weight / float(rest)))
if share >= dominate:
return ("schema-dominates",
shape + ". The machinery outweighs the conversation on every "
"call, cached or not.")
if share >= heavy:
return ("schema-heavy",
shape + ". Not dominant, and still the single largest stable "
"block in the prompt, which makes it the cheapest thing to "
"cache.")
return ("schema-modest", shape + ".")
def defer_candidates(rows, hot=(), keep_eager=1):
"""Tools that could carry defer_loading, and never all of them. Pure.
The API answers a request whose every tool defers with 400, "All tools have
defer_loading set". A function able to return the whole list is a function
that has already caused an outage, so at least one tool always stays eager
whatever the arithmetic says.
"""
names = [str(r.get("name")) for r in rows or [] if r.get("name")]
if len(names) <= keep_eager:
return []
hot_set = {str(h) for h in hot or []}
candidates = [n for n in names if n not in hot_set]
if len(candidates) >= len(names):
heaviest = sorted(rows, key=lambda r: -_int(r.get("tokens")))
eager = {str(r.get("name")) for r in heaviest[:max(1, keep_eager)]}
candidates = [n for n in names if n not in eager]
return candidates
def monthly_cost(tokens_per_call, calls_per_day, rate_per_mtok, days=30):
"""What one per-call token count costs in a month. Pure. None if unpriced."""
tokens = _int(tokens_per_call)
calls = _int(calls_per_day)
try:
rate = float(rate_per_mtok)
except (TypeError, ValueError):
return None
if tokens <= 0 or calls <= 0 or rate <= 0:
return None
return tokens * calls * int(days) / 1_000_000.0 * rate
def window_share(total, window):
"""Share of the model context window spent before the user speaks. Pure."""
size = _int(window)
if size <= 0:
return None
return min(1.0, _int(total) / float(size))
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)
if r.status_code == 404:
return {}
r.raise_for_status()
return r.json()
def count(session, body):
"""The one non-GET call. It creates nothing, generates nothing, bills nothing."""
r = session.post(API + "/messages/count_tokens", json=body, timeout=120)
if r.status_code >= 400:
log.warning("count_tokens answered %d: %s", r.status_code, r.text[:200])
return None
return _int((r.json() or {}).get("input_tokens"))
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("--calls-per-day", type=int, default=10000,
help="calls of this shape per day, for the monthly price")
ap.add_argument("--input-rate", type=float, default=3.0,
help="your model's uncached input rate per million tokens")
ap.add_argument("--hot", action="append", default=[],
help="a tool name that must stay eagerly loaded; repeatable")
ap.add_argument("--no-per-tool", action="store_true",
help="skip the per-tool ablation")
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"})
checked = 0
bad = 0
for path in args.payload:
with open(path, "r", encoding="utf-8") as fh:
body = json.load(fh)
checked += 1
total = count(session, countable(body))
base = count(session, without_tools(body))
if total is None or base is None:
log.warning("could not measure %s", path)
continue
state, detail = classify(total, base)
line = "%-18s %-24s %s" % (state, path, detail)
if state in FINDINGS:
bad += 1
log.warning(line)
else:
log.info(line)
model = str(body.get("model") or "")
kind = choice_kind(body)
fixed = system_prompt_tokens(model, kind)
if fixed is None:
log.info(" no published tool-use system prompt size for %r, so "
"the fixed charge cannot be separated out here", model)
else:
log.info(" %d of the overhead is the automatic tool-use system "
"prompt for %s at tool_choice %s", fixed, model, kind)
rows = []
if not args.no_per_tool:
for name in tool_names(body):
one = count(session, without_tool(body, name))
if one is None:
continue
rows.append({"name": name, "tokens": max(0, total - one)})
rows.sort(key=lambda r: -r["tokens"])
residual, measured = fixed_overhead(overhead(total, base), rows)
log.info(" the fixed charge no ablation removes: %d token(s); "
"your schemas account for %d", residual, measured)
if rows:
log.info(" heaviest: %s", ", ".join(
"%s %d" % (r["name"], r["tokens"]) for r in rows[:3]))
window = get(session, "/models/" + model).get("max_input_tokens") if model else None
share = window_share(total, window)
if share is not None:
log.info(" %.0f%% of the %d token context window is spent before "
"the user says anything. Whether a real conversation still "
"fits is the context-overflow question, not this one.",
share * 100, _int(window))
price = monthly_cost(overhead(total, base), args.calls_per_day,
args.input_rate)
if price is not None:
log.info(" at %d call(s) a day and %.2f per million input tokens "
"that is %.2f a month, uncached", args.calls_per_day,
args.input_rate, price)
if state in FINDINGS:
log.warning(" repair: put a cache_control breakpoint after the "
"tools block. A read costs 0.1x base input, and tools "
"are the most stable thing in the prompt.")
log.warning(" repair: editing any tool description after that "
"invalidates the tools, the system prompt and the "
"messages behind them. Batch tool edits.")
candidates = defer_candidates(rows, args.hot)
if candidates:
log.warning(" repair: defer_loading on rarely used tools only "
"(%s). Never on all of them: the API answers 400, "
"All tools have defer_loading set. Which are rare "
"is a call-coverage question this script cannot "
"answer.", ", ".join(candidates[:5]))
log.info("%d payload(s) measured, %d finding(s)", checked, bad)
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Measure what a Claude tools block costs in input tokens on every call.
*
* Read only. One GET for the model object and a handful of calls to
* /v1/messages/count_tokens, which is free, creates no object, generates no
* completion and is not billed. /v1/messages is never called.
*
* Count the body, count it again with tools removed, subtract. Ablate one tool
* at a time for a per-tool price, and note that the deltas do not sum to the
* whole: every ablated body still carries the tool-use system prompt.
*/
import { readFile } from 'node:fs/promises';
const API = 'https://api.anthropic.com/v1';
const VERSION = '2023-06-01';
// Fields the counting endpoint refuses, stripped identically from every body.
const SAMPLING_ONLY = new Set(['max_tokens', 'stream', 'temperature', 'top_p',
'top_k', 'stop_sequences', 'metadata', 'service_tier']);
// The automatic tool-use system prompt per model, as [autoOrNone, anyOrTool].
// Longest prefix wins: a substring test reads claude-opus-4-5 as claude-opus-5.
const TOOL_SYSTEM_PROMPT = {
'claude-opus-5': [286, 406],
'claude-opus-4-8': [290, 410],
'claude-opus-4-7': [675, 804],
'claude-opus-4-6': [497, 589],
'claude-sonnet-4-6': [497, 589],
'claude-sonnet-5': [354, 474],
'claude-opus-4-5': [496, 588],
'claude-sonnet-4-5': [496, 588],
'claude-haiku-4-5': [496, 588],
};
const FINDINGS = new Set(['schema-dominates', 'schema-heavy']);
/** Read a token count as an integer. Pure. Missing and unreadable both mean 0. */
export function readInt(value) {
const n = Number(value ?? 0);
return Number.isFinite(n) ? Math.trunc(n) : 0;
}
/** A body the counting endpoint will accept. Pure. Does not mutate. */
export function countable(body) {
if (!body || typeof body !== 'object') return {};
const out = {};
for (const [k, v] of Object.entries(body)) {
if (!SAMPLING_ONLY.has(k)) out[k] = structuredClone(v);
}
return out;
}
/**
* The same body with the whole tools block removed. Pure.
* tool_choice goes with it: a body naming a tool it no longer declares is
* rejected, and the rejection reads as a broken counter.
*/
export function withoutTools(body) {
const stripped = countable(body);
delete stripped.tools;
delete stripped.tool_choice;
return stripped;
}
/** Named tools in a body, in declaration order. Pure. */
export function toolNames(body) {
const out = [];
for (const tool of body?.tools ?? []) {
if (!tool || typeof tool !== 'object') continue;
const name = String(tool.name ?? '').trim();
if (name && !out.includes(name)) out.push(name);
}
return out;
}
/** The same body with exactly one tool removed. Pure. Does not mutate. */
export function withoutTool(body, name) {
const stripped = countable(body);
const kept = (stripped.tools ?? []).filter(
(t) => !(t && typeof t === 'object' && String(t.name ?? '') === String(name)));
stripped.tools = kept;
if (kept.length === 0) {
delete stripped.tools;
delete stripped.tool_choice;
}
return stripped;
}
/** Tokens attributable to the tools block. Pure. Never negative. */
export function overhead(total, base) {
return Math.max(0, readInt(total) - readInt(base));
}
/** Share of counted input the tools block accounts for. Pure. Null when none. */
export function overheadShare(total, base) {
const counted = readInt(total);
if (counted <= 0) return null;
return overhead(total, base) / counted;
}
/** Which column of the tool-use system prompt table applies. Pure. */
export function choiceKind(body) {
const choice = body?.tool_choice;
let kind = '';
if (typeof choice === 'string') kind = choice.trim().toLowerCase();
else if (choice && typeof choice === 'object') {
kind = String(choice.type ?? '').trim().toLowerCase();
}
return kind === 'any' || kind === 'tool' ? 'any' : 'auto';
}
/**
* The automatic tool-use system prompt for one model. Pure. Null if unlisted.
* Unlisted returns null rather than a neighbour's number: a plausible wrong
* value here silently corrupts the split between schemas and fixed charge.
*/
export function systemPromptTokens(model, kind = 'auto') {
const name = String(model ?? '').trim().toLowerCase();
let best = null;
let bestLen = -1;
for (const [prefix, sizes] of Object.entries(TOOL_SYSTEM_PROMPT)) {
if ((name === prefix || name.startsWith(`${prefix}-`)) && prefix.length > bestLen) {
best = sizes;
bestLen = prefix.length;
}
}
if (best === null) return null;
return String(kind).toLowerCase() === 'any' ? best[1] : best[0];
}
/**
* The part of the tool overhead that belongs to no single tool. Pure.
* Returns [residual, measured]. Ablation never removes the tool-use system
* prompt, so the deltas sum to the schema weight and the rest is fixed.
*/
export function fixedOverhead(totalOverhead, perTool) {
let measured = 0;
for (const row of perTool ?? []) measured += Math.max(0, readInt(row?.tokens));
return [Math.max(0, readInt(totalOverhead) - measured), measured];
}
/** Classify one measured payload. Pure. Returns [state, detail]. */
export function classify(total, base, dominate = 0.5, heavy = 0.25) {
const counted = readInt(total);
if (counted <= 0) {
return ['nothing-counted', 'the counting endpoint returned no tokens for this body'];
}
const weight = overhead(total, base);
if (weight <= 0) {
return ['no-tools', `${counted} input token(s) and no measurable tools block`];
}
const share = weight / counted;
const rest = counted - weight;
let shape = `${weight} of ${counted} input token(s) are the tools block ` +
`(${(share * 100).toFixed(0)}%)`;
if (rest > 0) {
shape += `, against ${rest} token(s) of system and messages, a ratio of ` +
`${(weight / rest).toFixed(1)} to 1`;
}
if (share >= dominate) {
return ['schema-dominates',
`${shape}. The machinery outweighs the conversation on every call, ` +
'cached or not.'];
}
if (share >= heavy) {
return ['schema-heavy',
`${shape}. Not dominant, and still the single largest stable block in ` +
'the prompt, which makes it the cheapest thing to cache.'];
}
return ['schema-modest', `${shape}.`];
}
/**
* Tools that could carry defer_loading, and never all of them. Pure.
* The API answers a fully deferred request with 400, "All tools have
* defer_loading set", so at least one tool always stays eager.
*/
export function deferCandidates(rows, hot = [], keepEager = 1) {
const names = (rows ?? []).map((r) => String(r?.name ?? '')).filter(Boolean);
if (names.length <= keepEager) return [];
const hotSet = new Set((hot ?? []).map(String));
let candidates = names.filter((n) => !hotSet.has(n));
if (candidates.length >= names.length) {
const heaviest = [...rows].sort((a, b) => readInt(b?.tokens) - readInt(a?.tokens));
const eager = new Set(heaviest.slice(0, Math.max(1, keepEager))
.map((r) => String(r?.name ?? '')));
candidates = names.filter((n) => !eager.has(n));
}
return candidates;
}
/** What one per-call token count costs in a month. Pure. Null if unpriced. */
export function monthlyCost(tokensPerCall, callsPerDay, ratePerMtok, days = 30) {
const tokens = readInt(tokensPerCall);
const calls = readInt(callsPerDay);
const rate = Number(ratePerMtok);
if (!Number.isFinite(rate) || tokens <= 0 || calls <= 0 || rate <= 0) return null;
return (tokens * calls * Math.trunc(days)) / 1000000 * rate;
}
/** Share of the model context window spent before the user speaks. Pure. */
export function windowShare(total, window) {
const size = readInt(window);
if (size <= 0) return null;
return Math.min(1, readInt(total) / size);
}
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.status === 404) return {};
if (!res.ok) throw new Error(`${res.status} from ${path}`);
return res.json();
}
/** The one non-GET call. It creates nothing, generates nothing, bills nothing. */
async function count(key, body) {
const res = await fetch(`${API}/messages/count_tokens`, {
method: 'POST', // count_tokens creates nothing and bills nothing
headers: headers(key),
body: JSON.stringify(body),
});
if (!res.ok) {
console.warn(`count_tokens answered ${res.status}`);
return null;
}
return readInt((await res.json())?.input_tokens);
}
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 callsPerDay = Number(process.env.CALLS_PER_DAY ?? 10000);
const inputRate = Number(process.env.INPUT_RATE ?? 3.0);
const hot = String(process.env.HOT ?? '').split(',').filter(Boolean);
const perTool = process.env.NO_PER_TOOL !== '1';
let checked = 0;
let bad = 0;
for (const path of paths) {
const body = JSON.parse(await readFile(path, 'utf8'));
checked += 1;
const total = await count(key, countable(body));
const base = await count(key, withoutTools(body));
if (total === null || base === null) {
console.warn(`could not measure ${path}`);
continue;
}
const [state, detail] = classify(total, base);
const line = `${state.padEnd(18)} ${path.padEnd(24)} ${detail}`;
if (FINDINGS.has(state)) {
bad += 1;
console.warn(line);
} else {
console.log(line);
}
const model = String(body.model ?? '');
const kind = choiceKind(body);
const fixed = systemPromptTokens(model, kind);
if (fixed === null) {
console.log(` no published tool-use system prompt size for ${model}, so ` +
'the fixed charge cannot be separated out here');
} else {
console.log(` ${fixed} of the overhead is the automatic tool-use system ` +
`prompt for ${model} at tool_choice ${kind}`);
}
let rows = [];
if (perTool) {
for (const name of toolNames(body)) {
const one = await count(key, withoutTool(body, name));
if (one === null) continue;
rows.push({ name, tokens: Math.max(0, total - one) });
}
rows.sort((a, b) => b.tokens - a.tokens);
const [residual, measured] = fixedOverhead(overhead(total, base), rows);
console.log(` the fixed charge no ablation removes: ${residual} token(s); ` +
`your schemas account for ${measured}`);
if (rows.length > 0) {
console.log(` heaviest: ${rows.slice(0, 3)
.map((r) => `${r.name} ${r.tokens}`).join(', ')}`);
}
}
const window = model ? (await get(key, `/models/${model}`))?.max_input_tokens : null;
const share = windowShare(total, window);
if (share !== null) {
console.log(` ${(share * 100).toFixed(0)}% of the ${readInt(window)} token ` +
'context window is spent before the user says anything. Whether ' +
'a real conversation still fits is the context-overflow ' +
'question, not this one.');
}
const price = monthlyCost(overhead(total, base), callsPerDay, inputRate);
if (price !== null) {
console.log(` at ${callsPerDay} call(s) a day and ${inputRate.toFixed(2)} ` +
`per million input tokens that is ${price.toFixed(2)} a month, uncached`);
}
if (FINDINGS.has(state)) {
console.warn(' repair: put a cache_control breakpoint after the tools ' +
'block. A read costs 0.1x base input, and tools are the most ' +
'stable thing in the prompt.');
console.warn(' repair: editing any tool description after that ' +
'invalidates the tools, the system prompt and the messages ' +
'behind them. Batch tool edits.');
const candidates = deferCandidates(rows, hot);
if (candidates.length > 0) {
console.warn(` repair: defer_loading on rarely used tools only ` +
`(${candidates.slice(0, 5).join(', ')}). Never on all of ` +
'them: the API answers 400, All tools have defer_loading ' +
'set. Which are rare is a call-coverage question this ' +
'script cannot answer.');
}
}
}
console.log(`${checked} payload(s) measured, ${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 subtraction the whole note rests on: 12,388 counted with tools and 888 without, so 11,500 tokens of the request are machinery and the conversation is seven percent of what you pay for. The second is the one that stops a plausible lie being printed — the per-tool ablation deltas sum to less than the total overhead, and the residual is asserted to be exactly the tool-use system prompt for the model in the body, because a script that reported the sum as the total would be wrong by several hundred tokens on every call and look right. The rest pin the longest-prefix lookup that must never read claude-opus-4-5 as claude-opus-5, the deferral picker that is structurally unable to return every tool and reproduce the documented 400, and the two body transforms, which have to remove tool_choice alongside the tools and leave everything else untouched.
from anthropic_tool_schema_overhead import (choice_kind, classify, countable,
defer_candidates, fixed_overhead,
monthly_cost, overhead,
overhead_share,
system_prompt_tokens, tool_names,
window_share, without_tool,
without_tools)
BODY = {
"model": "claude-opus-5",
"max_tokens": 1024,
"temperature": 0,
"system": "You are a support agent.",
"tool_choice": {"type": "auto"},
"messages": [{"role": "user", "content": "where is my order"}],
"tools": [
{"name": "search_knowledge_base", "input_schema": {"type": "object"}},
{"name": "create_ticket", "input_schema": {"type": "object"}},
{"name": "lookup_order", "input_schema": {"type": "object"}},
],
}
def test_the_tools_block_is_most_of_what_you_pay_for():
# The note in one assertion. Two free counts, one subtraction.
total, base = 12388, 888
assert overhead(total, base) == 11500
assert round(overhead_share(total, base), 4) == 0.9283
state, detail = classify(total, base)
assert state == "schema-dominates"
assert "11500 of 12388 input token(s) are the tools block (93%)" in detail
assert "888 token(s) of system and messages, a ratio of 13.0 to 1" in detail
# 11500 tokens on 10000 calls a day for 30 days at $3 per million.
assert monthly_cost(11500, 10000, 3.0) == 10350.0
def test_the_ablation_deltas_do_not_add_up_to_the_whole():
# The trap. Removing one tool never removes the tool-use system prompt, so
# the per-tool sum is the schema weight and the residual is the fixed
# charge. A script that printed the sum as the total would be wrong by 286
# tokens on every call and would look right.
per_tool = [{"name": "search_knowledge_base", "tokens": 6200},
{"name": "create_ticket", "tokens": 3100},
{"name": "lookup_order", "tokens": 1914}]
residual, measured = fixed_overhead(11500, per_tool)
assert measured == 11214
assert residual == 286
assert residual == system_prompt_tokens("claude-opus-5", "auto")
assert fixed_overhead(0, per_tool) == (0, 11214)
def test_the_system_prompt_table_matches_on_longest_prefix():
assert system_prompt_tokens("claude-opus-5") == 286
assert system_prompt_tokens("claude-opus-5", "any") == 406
assert system_prompt_tokens("claude-sonnet-5") == 354
# The one a careless substring match gets wrong: 4-5 is not 5.
assert system_prompt_tokens("claude-opus-4-5") == 496
assert system_prompt_tokens("claude-haiku-4-5-20251001") == 496
assert system_prompt_tokens("claude-opus-4-7", "any") == 804
# Unlisted returns nothing rather than a neighbour's number.
assert system_prompt_tokens("claude-fable-5") is None
assert system_prompt_tokens("") is None
assert system_prompt_tokens(None) is None
def test_removing_the_tools_removes_the_tool_choice_with_them():
stripped = without_tools(BODY)
assert "tools" not in stripped and "tool_choice" not in stripped
assert stripped["system"] == BODY["system"]
assert stripped["messages"] == BODY["messages"]
# And the original is untouched, or the second count measures the first.
assert len(BODY["tools"]) == 3 and "tool_choice" in BODY
one_out = without_tool(BODY, "create_ticket")
assert tool_names(one_out) == ["search_knowledge_base", "lookup_order"]
assert one_out["tool_choice"] == BODY["tool_choice"]
# Removing the last tool has to take tool_choice with it as well.
bare = without_tool({"tools": [{"name": "only"}], "tool_choice": "any"}, "only")
assert "tools" not in bare and "tool_choice" not in bare
def test_the_deferral_picker_can_never_return_every_tool():
rows = [{"name": "a", "tokens": 900}, {"name": "b", "tokens": 400},
{"name": "c", "tokens": 100}]
picked = defer_candidates(rows)
assert picked == ["b", "c"]
assert len(picked) < len(rows)
# Naming every tool hot leaves nothing to defer, which is also fine.
assert defer_candidates(rows, hot=["a", "b", "c"]) == []
assert defer_candidates(rows, hot=["a"]) == ["b", "c"]
assert defer_candidates([{"name": "only", "tokens": 10}]) == []
assert defer_candidates([]) == []
def test_the_counting_body_keeps_what_is_being_measured():
body = countable(BODY)
assert "max_tokens" not in body and "temperature" not in body
assert body["tools"] == BODY["tools"]
assert body["model"] == "claude-opus-5"
assert countable(None) == {}
assert choice_kind(BODY) == "auto"
assert choice_kind({"tool_choice": {"type": "tool", "name": "x"}}) == "any"
assert choice_kind({"tool_choice": "any"}) == "any"
assert choice_kind({}) == "auto"
def test_the_states_are_bounded_and_a_missing_number_stays_missing():
assert classify(1000, 900)[0] == "schema-modest"
assert classify(1000, 700)[0] == "schema-heavy"
assert classify(1000, 500)[0] == "schema-dominates"
assert classify(1000, 1000)[0] == "no-tools"
assert classify(0, 0)[0] == "nothing-counted"
assert overhead_share(0, 0) is None
assert overhead(500, 900) == 0
assert monthly_cost(11500, 0, 3.0) is None
assert monthly_cost(11500, 10, "free") is None
assert window_share(12388, 200000) == 0.06194
assert window_share(12388, 0) is None
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { choiceKind, classify, countable, deferCandidates, fixedOverhead,
monthlyCost, overhead, overheadShare, systemPromptTokens, toolNames,
windowShare, withoutTool, withoutTools }
from './anthropic-tool-schema-overhead.mjs';
const BODY = {
model: 'claude-opus-5',
max_tokens: 1024,
temperature: 0,
system: 'You are a support agent.',
tool_choice: { type: 'auto' },
messages: [{ role: 'user', content: 'where is my order' }],
tools: [
{ name: 'search_knowledge_base', input_schema: { type: 'object' } },
{ name: 'create_ticket', input_schema: { type: 'object' } },
{ name: 'lookup_order', input_schema: { type: 'object' } },
],
};
test('the tools block is most of what you pay for', () => {
const total = 12388;
const base = 888;
assert.equal(overhead(total, base), 11500);
assert.equal(Number(overheadShare(total, base).toFixed(4)), 0.9283);
const [state, detail] = classify(total, base);
assert.equal(state, 'schema-dominates');
assert.match(detail, /11500 of 12388 input token/);
assert.match(detail, /888 token\(s\) of system and messages, a ratio of 13.0 to 1/);
assert.equal(monthlyCost(11500, 10000, 3.0), 10350);
});
test('the ablation deltas do not add up to the whole', () => {
const perTool = [{ name: 'search_knowledge_base', tokens: 6200 },
{ name: 'create_ticket', tokens: 3100 },
{ name: 'lookup_order', tokens: 1914 }];
const [residual, measured] = fixedOverhead(11500, perTool);
assert.equal(measured, 11214);
assert.equal(residual, 286);
assert.equal(residual, systemPromptTokens('claude-opus-5', 'auto'));
assert.deepEqual(fixedOverhead(0, perTool), [0, 11214]);
});
test('the system prompt table matches on longest prefix', () => {
assert.equal(systemPromptTokens('claude-opus-5'), 286);
assert.equal(systemPromptTokens('claude-opus-5', 'any'), 406);
assert.equal(systemPromptTokens('claude-sonnet-5'), 354);
assert.equal(systemPromptTokens('claude-opus-4-5'), 496);
assert.equal(systemPromptTokens('claude-haiku-4-5-20251001'), 496);
assert.equal(systemPromptTokens('claude-opus-4-7', 'any'), 804);
assert.equal(systemPromptTokens('claude-fable-5'), null);
assert.equal(systemPromptTokens(''), null);
assert.equal(systemPromptTokens(null), null);
});
test('removing the tools removes the tool_choice with them', () => {
const stripped = withoutTools(BODY);
assert.equal('tools' in stripped, false);
assert.equal('tool_choice' in stripped, false);
assert.equal(stripped.system, BODY.system);
assert.deepEqual(stripped.messages, BODY.messages);
assert.equal(BODY.tools.length, 3);
assert.equal('tool_choice' in BODY, true);
const oneOut = withoutTool(BODY, 'create_ticket');
assert.deepEqual(toolNames(oneOut), ['search_knowledge_base', 'lookup_order']);
assert.deepEqual(oneOut.tool_choice, BODY.tool_choice);
const bare = withoutTool({ tools: [{ name: 'only' }], tool_choice: 'any' }, 'only');
assert.equal('tools' in bare, false);
assert.equal('tool_choice' in bare, false);
});
test('the deferral picker can never return every tool', () => {
const rows = [{ name: 'a', tokens: 900 }, { name: 'b', tokens: 400 },
{ name: 'c', tokens: 100 }];
const picked = deferCandidates(rows);
assert.deepEqual(picked, ['b', 'c']);
assert.ok(picked.length < rows.length);
assert.deepEqual(deferCandidates(rows, ['a', 'b', 'c']), []);
assert.deepEqual(deferCandidates(rows, ['a']), ['b', 'c']);
assert.deepEqual(deferCandidates([{ name: 'only', tokens: 10 }]), []);
assert.deepEqual(deferCandidates([]), []);
});
test('the counting body keeps what is being measured', () => {
const body = countable(BODY);
assert.equal('max_tokens' in body, false);
assert.equal('temperature' in body, false);
assert.deepEqual(body.tools, BODY.tools);
assert.equal(body.model, 'claude-opus-5');
assert.deepEqual(countable(null), {});
assert.equal(choiceKind(BODY), 'auto');
assert.equal(choiceKind({ tool_choice: { type: 'tool', name: 'x' } }), 'any');
assert.equal(choiceKind({ tool_choice: 'any' }), 'any');
assert.equal(choiceKind({}), 'auto');
});
test('the states are bounded and a missing number stays missing', () => {
assert.equal(classify(1000, 900)[0], 'schema-modest');
assert.equal(classify(1000, 700)[0], 'schema-heavy');
assert.equal(classify(1000, 500)[0], 'schema-dominates');
assert.equal(classify(1000, 1000)[0], 'no-tools');
assert.equal(classify(0, 0)[0], 'nothing-counted');
assert.equal(overheadShare(0, 0), null);
assert.equal(overhead(500, 900), 0);
assert.equal(monthlyCost(11500, 0, 3.0), null);
assert.equal(monthlyCost(11500, 10, 'free'), null);
assert.equal(windowShare(12388, 200000), 0.06194);
assert.equal(windowShare(12388, 0), null);
});
FAQ
Does count_tokens cost anything or generate anything?
No to both. It runs the tokenizer over the body and returns an input_tokens number. No message is created, no completion is generated and nothing is billed, which is why it is the one non-GET call the scripts in this section are allowed to make. It is also exact rather than an estimate: it is the same tokenizer that will run when you actually send the request.
Why do the per-tool numbers not add up to the total overhead?
Because every ablated body still has tools in it. The automatic tool-use system prompt is added whenever any tool is present, so removing one tool at a time never removes it, and the deltas therefore measure schemas only. The leftover is that fixed charge. It is several hundred tokens on every call, it is not in your code, and pruning tools does not touch it.
Is caching the tool block safe if the tools change occasionally?
Yes, and the cost of a change is worth knowing before you make it. Tools sit first in the cache order, ahead of the system prompt and the messages, so any edit to a tool definition invalidates everything behind it and the next call pays a full write at 1.25x. That is fine monthly and painful hourly, so batch tool edits into a deploy rather than trickling them.
Should I put defer_loading on all the rarely used tools?
On the rarely used ones, yes. On all of them, no: the API rejects a request in which every tool defers with a 400 reading All tools have defer_loading set, which is why the function that picks candidates here always keeps at least one eager. Deciding which tools are rare needs call data, and that is a different note in this section.
The bill is fine but I keep getting 429s. Is this the same problem?
Related cause, different note. A large fixed prefix does eat input-tokens-per-minute headroom, but the finding there is which limiter emptied and the fix is throughput rather than cost. This note measures per-call weight and prices it. If the symptom is rate limiting rather than an invoice, start with the input-tokens-per-minute note and come back here for the size of the block.
Related field notes
- Which declared tools the model has never once chosen
- A prefix that changes every call, so nothing is ever read back
- The same payload measured against the model context window
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.
- Tool use overview — Claude Docs
- Token counting — Claude Docs
- Pricing — Claude Docs
- Prompt caching — Claude Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.