Diagnostic LLM APIs
Cost lands in the default workspace and cannot be charged back
The chargeback spreadsheet has one row that never shrinks. Four workspaces are named after the teams that own them and add up to about sixty per cent of the bill; the rest arrives under a heading somebody typed once as Unallocated and has been carrying forward every month since. It is not a rounding error and it is not fraud. It is the default workspace, and it is reported as null, which is not a workspace you can go and talk to.
Three GETs with an Admin API key. GET /v1/organizations/cost_report?starting_at={T-30d}&limit=31&group_by[]=workspace_id sizes the bucket: sum amount where workspace_id is null and take its share. Usage and costs in the organization's default workspace report a null workspace id, so that share is the part of your bill with no team on it.
Then split the null bucket into its two causes, because they are not the same problem. GET /v1/organizations/usage_report/messages?starting_at={T-30d}&bucket_width=1d&limit=31&group_by[]=api_key_id&group_by[]=workspace_id returns rows where api_key_id is also null — that is Console playground traffic, which is not associated with any API key at all and therefore cannot be moved anywhere by changing a key. Everything else in the null bucket came from a key.
Finally, name those keys. GET /v1/organizations/api_keys?limit=100, paged on has_more and last_id. Read scope.type: an organization-scoped key is not bound to a workspace at all, and a workspace-scoped key whose workspace id does not resolve is bound to the default one. Those keys are the movable half of the finding, and the output is a list of them with the named workspace each ought to move to.
The default workspace also cannot carry a rate-limit override. So this bucket is not only the part of the bill nobody owns, it is the part that cannot be bounded below the organization limit either.
The problem in plain words
null means "cannot attribute" twice over in these reports, and the two meanings have different repairs. Cost and usage in the default workspace come back with workspace_id: null because the default workspace has no id to report. Usage from the Console playground comes back with api_key_id: null because no key was involved in the request. A chargeback report that adds those together produces one large unallocated number and no next action.
The default workspace fills up because it is the path of least resistance and nothing pushes back. Every user whose org role permits API access can use it, in addition to whatever workspaces they were explicitly added to, and so can every service account. A key created in a hurry lands there. A key created before the workspace structure existed stays there. An organization-scoped key was never in a workspace to begin with.
And it is the one workspace you cannot put a guard on. Rate-limit overrides are configured per workspace and the default workspace cannot have them, so traffic there is unbounded relative to the organization limit — it can consume the whole org allocation and starve the named workspaces that are being careful.
Why it happens
This is the fixable cousin of a question that has no answer, and confusing them wastes a quarter. A published note explains why per-customer cost is unknowable: your tenant is nowhere in the attribution chain, the chain runs from request to key to key owner, and nothing you send can change that. This is a smaller and much better-behaved problem. The null bucket here decomposes into a finite list of API keys, each with an id and a name, each of which can be recreated inside a named workspace. There is a repair, it is boring, and it works.
Splitting the bucket is the whole value of the script, because half of it has no repair. Playground traffic carries no key. You cannot move it, rescope it, or attribute it, and an audit that reports "move these keys and the unallocated row goes away" is wrong in exactly the proportion that the playground contributed. The script reports the split first and grades the finding on it: when the playground is the majority of the null usage, the state says so and the repair is a conversation about where people run experiments, not a key migration.
scope.type is the field, and the deprecated top-level workspace_id is the trap. For a key bound to the default workspace the deprecated top-level field is null while scope.workspace_id carries the real id, so a reader that consults only one of them will misclassify keys in both directions. The script resolves the scope id first, falls back to the deprecated field, and refuses to classify a scope type it does not recognise rather than guessing that an unknown scope is harmless.
An unallocated share is not automatically a finding. Small organizations run everything in the default workspace deliberately and correctly; a hundred per cent null share in an org with one workspace is a topology fact, not an attribution failure, and it belongs to the boundary note. This script grades the share against a threshold, states the dollar figure behind it, and says nothing at all when the window has no spend in it.
The fix, as a flow
One unallocated row on a chargeback report, and two entirely different causes underneath it. Keys that land in the default workspace have names and ids and can be replaced inside a named one. Console playground requests carry no key at all, so no migration touches them. Reporting the bucket as a single number promises a fix that only works on part of it.
How to fix it
Use an Admin API key, provisioned read-only
Every /v1/organizations/* path rejects a workspace key. An Admin key with read scopes covers all three calls, and there is no fourth.
Size the null bucket on the cost report
GET /v1/organizations/cost_report?starting_at={T-30d}&limit=31&group_by[]=workspace_id. amount arrives as a decimal string, so parse it rather than summing raw values. The share of total with a null workspace_id is the number the chargeback spreadsheet has been carrying forward.
Split it into playground traffic and key traffic
group_by[]=api_key_id&group_by[]=workspace_id on the messages usage report. A row with a null api_key_id is Console playground usage and has no key to move; classify that first, because a playground request made in the default workspace has both fields null and would otherwise be counted twice.
Name the keys whose scope explains the rest
GET /v1/organizations/api_keys?limit=100. Keep status == "active". Resolve each key's workspace as scope.workspace_id first and the deprecated top-level workspace_id second; a key that resolves to nothing is in the default workspace, and a key whose scope.type is organization was never in a workspace at all.
Print the movable list, and say what will not move
Per key: id, name, scope, and the fact that it lands in the default workspace. The repair is to recreate the key inside a named workspace and cut over — and the output states the playground share explicitly, because that part of the bucket will still be there afterwards.
How to check it worked
Move one key, wait a day, and re-read the same window. The null share should fall by roughly that key's volume and no more. If it does not fall at all, the remainder is playground traffic or a key that was deleted before the window closed, and the script's split already told you which to expect.
python3 anthropic_default_workspace_cost.py --days 30
# $41,208.55 in the last 30 day(s) across 5 workspace row(s)
# unattributed: $15,706.09 (38% of spend) has a null workspace_id
# usage split of the null bucket: 91% from API keys, 9% Console playground
# movable-keys 38% of $41,208.55 has no workspace on it, and 4 active key(s)
# land in the default workspace or carry organization scope.
# apikey_01aa nightly-summaries organization-scoped
# apikey_01bb ingest-worker default-workspace
# apikey_01cc eval-runner default-workspace
# apikey_01dd adam-scratch default-workspace
# repair: recreate each key inside a named workspace and cut over, key by key
# repair: 9% of the null usage is Console playground and no key move touches it
# repair: the default workspace cannot carry a rate-limit override at all
# 1 finding(s)
The full code
Three paged GETs and seven pure functions. The decimal-string amount parser; the cost fold, which keeps the null workspace under an explicit sentinel rather than dropping it; the token weigher, which has to walk the nested cache_creation object; the usage split, which classifies a null api_key_id before a null workspace_id so a playground request in the default workspace is counted once; the scope resolver, which prefers scope.workspace_id over the deprecated top-level field and refuses to classify an unknown scope type; the verdict, which grades the movable half against the playground half; and the repair lines, which always state the part that will not move.
"""Find Anthropic cost that reports no workspace, and the keys behind it.
Read only. Three paged GETs against /v1/organizations/* with an Admin API key.
Nothing is sent to /v1/messages and no request body is constructed.
The unallocated bucket has two causes and only one of them has a repair. Cost
and usage in the organization's default workspace report workspace_id: null,
and Console playground usage reports api_key_id: null because no key was
involved. Keys can be moved; playground traffic cannot, so the script sizes
both before it recommends anything.
Key values are never read or printed. The key listing is used for ids, names
and scope only.
"""
import argparse
import datetime as dt
import logging
import os
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("anthropic_default_workspace_cost")
API = "https://api.anthropic.com/v1"
VERSION = "2023-06-01"
# The default workspace has no id to report, so the reports return null for it.
# Kept under an explicit sentinel rather than dropped, because dropping it is
# how a chargeback report silently stops adding up to the invoice.
DEFAULT_WS = "(default workspace)"
PLAYGROUND = "console-playground"
DEFAULT_KEYED = "default-workspace"
ATTRIBUTED = "attributed"
ORG_SCOPED = "organization-scoped"
NAMED = "named-workspace"
UNKNOWN_SCOPE = "unknown-scope"
MOVABLE = (ORG_SCOPED, DEFAULT_KEYED)
FINDINGS = ("movable-keys", "console-playground", "unattributable-no-key-to-move")
def amount(row):
"""One cost row's amount as a float. Pure.
The cost report returns amount as a decimal STRING. Summing the raw values
concatenates them, which produces a number large enough that nobody reads
it as money and small enough that nobody notices it is text.
"""
try:
return float((row or {}).get("amount") or 0.0)
except (TypeError, ValueError):
return 0.0
def cost_by_workspace(pages):
"""{workspace_id: dollars} from the cost report. Pure. Null uses a sentinel."""
rows = {}
for page in pages or []:
for bucket in (page or {}).get("data") or []:
for result in (bucket or {}).get("results") or []:
key = (result or {}).get("workspace_id") or DEFAULT_WS
rows[str(key)] = rows.get(str(key), 0.0) + amount(result)
return rows
def unattributed_share(rows):
"""The null workspace's share of total cost. Pure. 0.0 when empty."""
data = rows or {}
total = sum(data.values())
if total <= 0:
return 0.0
return float(data.get(DEFAULT_WS, 0.0)) / total
def weigh(result):
"""Total billed tokens on one usage row. Pure.
cache_creation is an object rather than a scalar, so a reader that treats
it as an int drops every cached write out of the weight.
"""
row = result or {}
total = 0
for field in ("uncached_input_tokens", "cache_read_input_tokens",
"output_tokens"):
try:
total += int(row.get(field) or 0)
except (TypeError, ValueError):
pass
creation = row.get("cache_creation")
if isinstance(creation, dict):
for value in creation.values():
try:
total += int(value or 0)
except (TypeError, ValueError):
pass
return total
def usage_split(pages):
"""{cause: tokens} across the usage report. Pure.
A null api_key_id is classified BEFORE a null workspace_id. A playground
request made in the default workspace has both fields null, and counting it
in both buckets inflates the movable half of the finding, which is the half
the script is about to recommend work on.
"""
out = {PLAYGROUND: 0, DEFAULT_KEYED: 0, ATTRIBUTED: 0}
for page in pages or []:
for bucket in (page or {}).get("data") or []:
for result in (bucket or {}).get("results") or []:
row = result or {}
tokens = weigh(row)
if not row.get("api_key_id"):
out[PLAYGROUND] += tokens
elif not row.get("workspace_id"):
out[DEFAULT_KEYED] += tokens
else:
out[ATTRIBUTED] += tokens
return out
def playground_share(split):
"""Playground share of the null bucket only. Pure. 0.0 when the bucket is empty."""
data = split or {}
null_bucket = int(data.get(PLAYGROUND, 0)) + int(data.get(DEFAULT_KEYED, 0))
if null_bucket <= 0:
return 0.0
return int(data.get(PLAYGROUND, 0)) / float(null_bucket)
def key_attribution(key):
"""Where one API key's traffic lands. Pure. Returns (kind, workspace_id).
scope.workspace_id is read before the deprecated top-level workspace_id,
which is null for keys bound to the default workspace. An unrecognised
scope type is returned as unknown rather than assumed harmless.
"""
row = key or {}
scope = row.get("scope") or {}
kind = str(scope.get("type") or "").strip().lower()
workspace = scope.get("workspace_id") or row.get("workspace_id")
if kind == "organization":
return (ORG_SCOPED, None)
if kind and kind != "workspace":
return (UNKNOWN_SCOPE, workspace and str(workspace) or None)
if workspace:
return (NAMED, str(workspace))
return (DEFAULT_KEYED, None)
def fold_keys(keys):
"""{kind: [{id, name, workspace_id}]} over ACTIVE keys only. Pure.
An inactive key cannot be the cause of spend in the window and must not
appear in a migration list somebody is going to work through by hand.
"""
out = {ORG_SCOPED: [], DEFAULT_KEYED: [], NAMED: [], UNKNOWN_SCOPE: []}
for key in keys or []:
row = key or {}
if str(row.get("status") or "active").strip().lower() != "active":
continue
kind, workspace = key_attribution(row)
out[kind].append({"id": str(row.get("id") or "unknown"),
"name": str(row.get("name") or "unnamed"),
"workspace_id": workspace})
return out
def verdict(share, total, folded, split, min_spend=1.0, min_share=0.10,
playground_max=0.50):
"""Classify the unallocated bucket. Pure. Returns (state, detail)."""
movable = sum(len(folded.get(kind) or []) for kind in MOVABLE)
if total < min_spend:
return ("no-spend-yet",
"$%s of cost in the window, too little to conclude anything"
% format(total, ",.2f"))
if share < min_share:
return ("attributed",
"%.0f%% of $%s has a null workspace_id, under the threshold"
% (share * 100, format(total, ",.2f")))
plays = playground_share(split)
if plays > playground_max:
return ("console-playground",
"%.0f%% of $%s has no workspace on it, and %.0f%% of that usage "
"carries no api_key_id either. That is Console playground "
"traffic, and no key can be moved to make it land anywhere."
% (share * 100, format(total, ",.2f"), plays * 100))
if movable:
return ("movable-keys",
"%.0f%% of $%s has no workspace on it, and %d active key(s) "
"land in the default workspace or carry organization scope."
% (share * 100, format(total, ",.2f"), movable))
return ("unattributable-no-key-to-move",
"%.0f%% of $%s has no workspace on it, and every active key "
"resolves to a named workspace. The spend came from a key that has "
"since been deleted, or from the playground."
% (share * 100, format(total, ",.2f")))
def repair_lines(state, folded, split):
"""The repair for one verdict. Pure. Printed, never performed."""
plays = playground_share(split)
if state == "movable-keys":
lines = ["recreate each key inside a named workspace and cut over, key "
"by key. A key's workspace is fixed when it is created."]
if folded.get(ORG_SCOPED):
lines.append("%d of them carry organization scope, which is not a "
"workspace at all: those cannot be reassigned, only "
"replaced." % len(folded[ORG_SCOPED]))
if plays > 0:
lines.append("%.0f%% of the null usage is Console playground and no "
"key move touches it." % (plays * 100))
lines.append("the default workspace cannot carry a rate-limit override "
"at all, so this traffic is also unbounded relative to the "
"organization limit.")
return lines
if state == "console-playground":
return [
"there is no key migration here. The requests carried no key.",
"decide where experiments should run: a named workspace with its "
"own key, or an accepted line in the chargeback report.",
"the default workspace cannot carry a rate-limit override, so "
"playground traffic competes with production for the org limit.",
]
if state == "unattributable-no-key-to-move":
return [
"do not open a migration ticket. Every active key already resolves "
"to a named workspace.",
"the spend predates a key deletion or came from the playground; "
"narrow the window and read the daily buckets to see which.",
]
return []
def get(session, path, params):
r = session.get(API + path, params=params, timeout=90)
if r.status_code in (401, 403):
raise SystemExit("%d from Anthropic: /v1/organizations/* needs an Admin "
"API key (sk-ant-admin...), not a workspace key"
% r.status_code)
r.raise_for_status()
return r.json()
def report_pages(session, path, params):
"""Walk a usage or cost report on next_page."""
params = dict(params)
while True:
page = get(session, path, params)
yield page
if not page.get("has_more") or not page.get("next_page"):
return
params["page"] = page["next_page"]
def listing(session, path, params):
"""Walk an Admin list endpoint on after_id."""
params = dict(params)
while True:
page = get(session, path, params)
for item in page.get("data") or []:
yield item
if not page.get("has_more") or not page.get("last_id"):
return
params["after_id"] = page["last_id"]
def window_start(days, now=None):
"""Floor to midnight UTC: starting_at must sit on a bucket boundary."""
now = now or dt.datetime.now(dt.timezone.utc)
midnight = now.replace(hour=0, minute=0, second=0, microsecond=0)
return (midnight - dt.timedelta(days=days)).strftime("%Y-%m-%dT%H:%M:%SZ")
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--days", type=int, default=30,
help="days of daily buckets to read (default 30)")
ap.add_argument("--min-share", type=float, default=0.10,
help="null share below which nothing is reported")
args = ap.parse_args()
admin = os.environ.get("ANTHROPIC_ADMIN_KEY")
if not admin:
log.error("set ANTHROPIC_ADMIN_KEY to an Admin API key (sk-ant-admin...); "
"a workspace key cannot read /v1/organizations/*")
return 2
s = requests.Session()
s.headers.update({"x-api-key": admin, "anthropic-version": VERSION})
start = window_start(args.days)
costs = cost_by_workspace(report_pages(
s, "/organizations/cost_report",
{"starting_at": start, "limit": min(args.days + 1, 31),
"group_by[]": ["workspace_id"]}))
total = round(sum(costs.values()), 2)
share = unattributed_share(costs)
split = usage_split(report_pages(
s, "/organizations/usage_report/messages",
{"starting_at": start, "bucket_width": "1d",
"limit": min(args.days + 1, 31),
"group_by[]": ["api_key_id", "workspace_id"]}))
folded = fold_keys(listing(s, "/organizations/api_keys", {"limit": 100}))
log.info("$%s in the last %d day(s) across %d workspace row(s)",
format(total, ",.2f"), args.days, len(costs))
log.info("unattributed: $%s (%.0f%% of spend) has a null workspace_id",
format(costs.get(DEFAULT_WS, 0.0), ",.2f"), share * 100)
plays = playground_share(split)
log.info("usage split of the null bucket: %.0f%% from API keys, %.0f%% "
"Console playground", (1 - plays) * 100, plays * 100)
state, detail = verdict(share, total, folded, split, min_share=args.min_share)
if state not in FINDINGS:
log.info("%-18s %s", state, detail)
return 0
log.warning("%-18s %s", state, detail)
for kind in MOVABLE:
for key in folded.get(kind) or []:
log.warning(" %-12s %-22s %s", key["id"], key["name"], kind)
for line in repair_lines(state, folded, split):
log.warning(" repair: %s", line)
log.info("1 finding(s)")
return 1
if __name__ == "__main__":
sys.exit(main())
/**
* Find Anthropic cost that reports no workspace, and the keys behind it.
*
* Read only. Three paged GETs against /v1/organizations/* with an Admin API
* key. No request body is constructed and no key value is read or printed.
*
* The unallocated bucket has two causes and only one has a repair: keys that
* land in the default workspace can be moved, and Console playground traffic
* carries no key at all.
*/
const API = 'https://api.anthropic.com/v1';
const VERSION = '2023-06-01';
const DEFAULT_WS = '(default workspace)';
const PLAYGROUND = 'console-playground';
const DEFAULT_KEYED = 'default-workspace';
const ATTRIBUTED = 'attributed';
const ORG_SCOPED = 'organization-scoped';
const NAMED = 'named-workspace';
const UNKNOWN_SCOPE = 'unknown-scope';
const MOVABLE = [ORG_SCOPED, DEFAULT_KEYED];
const FINDINGS = new Set(['movable-keys', 'console-playground',
'unattributable-no-key-to-move']);
const money = (n) => Number(n).toLocaleString('en-US',
{ minimumFractionDigits: 2, maximumFractionDigits: 2 });
/** One cost row's amount as a number. Pure. amount is a decimal STRING. */
export function amount(row) {
const value = Number(row?.amount ?? 0);
return Number.isFinite(value) ? value : 0;
}
/** {workspace_id: dollars} from the cost report. Pure. Null uses a sentinel. */
export function costByWorkspace(pages) {
const rows = {};
for (const page of pages ?? []) {
for (const bucket of page?.data ?? []) {
for (const result of bucket?.results ?? []) {
const key = String(result?.workspace_id ?? DEFAULT_WS);
rows[key] = (rows[key] ?? 0) + amount(result);
}
}
}
return rows;
}
/** The null workspace's share of total cost. Pure. */
export function unattributedShare(rows) {
const data = rows ?? {};
const total = Object.values(data).reduce((a, v) => a + v, 0);
if (total <= 0) return 0;
return (data[DEFAULT_WS] ?? 0) / total;
}
/** Total billed tokens on one usage row. Pure. cache_creation is an object. */
export function weigh(result) {
const row = result ?? {};
let total = 0;
for (const field of ['uncached_input_tokens', 'cache_read_input_tokens',
'output_tokens']) {
const value = Number(row[field] ?? 0);
if (Number.isFinite(value)) total += value;
}
const creation = row.cache_creation;
if (creation && typeof creation === 'object' && !Array.isArray(creation)) {
for (const value of Object.values(creation)) {
const n = Number(value ?? 0);
if (Number.isFinite(n)) total += n;
}
}
return total;
}
/** {cause: tokens}. Pure. A null api_key_id is classified before a null workspace. */
export function usageSplit(pages) {
const out = { [PLAYGROUND]: 0, [DEFAULT_KEYED]: 0, [ATTRIBUTED]: 0 };
for (const page of pages ?? []) {
for (const bucket of page?.data ?? []) {
for (const result of bucket?.results ?? []) {
const tokens = weigh(result);
if (!result?.api_key_id) out[PLAYGROUND] += tokens;
else if (!result?.workspace_id) out[DEFAULT_KEYED] += tokens;
else out[ATTRIBUTED] += tokens;
}
}
}
return out;
}
/** Playground share of the null bucket only. Pure. */
export function playgroundShare(split) {
const data = split ?? {};
const bucket = (data[PLAYGROUND] ?? 0) + (data[DEFAULT_KEYED] ?? 0);
if (bucket <= 0) return 0;
return (data[PLAYGROUND] ?? 0) / bucket;
}
/** Where one API key's traffic lands. Pure. Returns [kind, workspaceId]. */
export function keyAttribution(key) {
const row = key ?? {};
const scope = row.scope ?? {};
const kind = String(scope.type ?? '').trim().toLowerCase();
const workspace = scope.workspace_id ?? row.workspace_id ?? null;
if (kind === 'organization') return [ORG_SCOPED, null];
if (kind && kind !== 'workspace') {
return [UNKNOWN_SCOPE, workspace ? String(workspace) : null];
}
if (workspace) return [NAMED, String(workspace)];
return [DEFAULT_KEYED, null];
}
/** {kind: [{id, name, workspace_id}]} over ACTIVE keys only. Pure. */
export function foldKeys(keys) {
const out = { [ORG_SCOPED]: [], [DEFAULT_KEYED]: [], [NAMED]: [],
[UNKNOWN_SCOPE]: [] };
for (const key of keys ?? []) {
const row = key ?? {};
if (String(row.status ?? 'active').trim().toLowerCase() !== 'active') continue;
const [kind, workspace] = keyAttribution(row);
out[kind].push({ id: String(row.id ?? 'unknown'),
name: String(row.name ?? 'unnamed'),
workspace_id: workspace });
}
return out;
}
/** Classify the unallocated bucket. Pure. Returns [state, detail]. */
export function verdict(share, total, folded, split, minSpend = 1.0,
minShare = 0.10, playgroundMax = 0.50) {
const movable = MOVABLE.reduce((a, kind) => a + (folded?.[kind]?.length ?? 0), 0);
if (total < minSpend) {
return ['no-spend-yet',
`$${money(total)} of cost in the window, too little to conclude anything`];
}
if (share < minShare) {
return ['attributed',
`${(share * 100).toFixed(0)}% of $${money(total)} has a null `
+ 'workspace_id, under the threshold'];
}
const plays = playgroundShare(split);
if (plays > playgroundMax) {
return ['console-playground',
`${(share * 100).toFixed(0)}% of $${money(total)} has no workspace on it, `
+ `and ${(plays * 100).toFixed(0)}% of that usage carries no api_key_id `
+ 'either. That is Console playground traffic, and no key can be moved '
+ 'to make it land anywhere.'];
}
if (movable) {
return ['movable-keys',
`${(share * 100).toFixed(0)}% of $${money(total)} has no workspace on it, `
+ `and ${movable} active key(s) land in the default workspace or carry `
+ 'organization scope.'];
}
return ['unattributable-no-key-to-move',
`${(share * 100).toFixed(0)}% of $${money(total)} has no workspace on it, `
+ 'and every active key resolves to a named workspace. The spend came from '
+ 'a key that has since been deleted, or from the playground.'];
}
/** The repair for one verdict. Pure. Printed, never performed. */
export function repairLines(state, folded, split) {
const plays = playgroundShare(split);
if (state === 'movable-keys') {
const lines = ['recreate each key inside a named workspace and cut over, key by '
+ "key. A key's workspace is fixed when it is created."];
if (folded?.[ORG_SCOPED]?.length) {
lines.push(`${folded[ORG_SCOPED].length} of them carry organization scope, `
+ 'which is not a workspace at all: those cannot be reassigned, only replaced.');
}
if (plays > 0) {
lines.push(`${(plays * 100).toFixed(0)}% of the null usage is Console `
+ 'playground and no key move touches it.');
}
lines.push('the default workspace cannot carry a rate-limit override at all, so '
+ 'this traffic is also unbounded relative to the organization limit.');
return lines;
}
if (state === 'console-playground') {
return [
'there is no key migration here. The requests carried no key.',
'decide where experiments should run: a named workspace with its own key, or '
+ 'an accepted line in the chargeback report.',
'the default workspace cannot carry a rate-limit override, so playground '
+ 'traffic competes with production for the org limit.',
];
}
if (state === 'unattributable-no-key-to-move') {
return [
'do not open a migration ticket. Every active key already resolves to a '
+ 'named workspace.',
'the spend predates a key deletion or came from the playground; narrow the '
+ 'window and read the daily buckets to see which.',
];
}
return [];
}
/** Floor to midnight UTC: starting_at must sit on a bucket boundary. Pure given now. */
export function windowStart(days, now = new Date()) {
const midnight = Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate());
return `${new Date(midnight - days * 86400000).toISOString().slice(0, 19)}Z`;
}
async function read(key, path, params) {
const url = new URL(API + path);
for (const [k, v] of Object.entries(params)) {
if (Array.isArray(v)) for (const one of v) url.searchParams.append(k, String(one));
else url.searchParams.set(k, String(v));
}
const r = await fetch(url, {
headers: { 'x-api-key': key, 'anthropic-version': VERSION },
});
if (r.status === 401 || r.status === 403) {
throw new Error(`${r.status} from Anthropic: /v1/organizations/* needs an Admin `
+ 'API key (sk-ant-admin...), not a workspace key');
}
if (!r.ok) throw new Error(`${r.status} ${await r.text()}`);
return r.json();
}
async function reportPages(key, path, params) {
const out = [];
const q = { ...params };
for (;;) {
const page = await read(key, path, q);
out.push(page);
if (!page.has_more || !page.next_page) return out;
q.page = page.next_page;
}
}
async function listing(key, path, params) {
const out = [];
const q = { ...params };
for (;;) {
const page = await read(key, path, q);
out.push(...(page.data ?? []));
if (!page.has_more || !page.last_id) return out;
q.after_id = page.last_id;
}
}
async function main() {
const admin = process.env.ANTHROPIC_ADMIN_KEY;
if (!admin) {
console.error('set ANTHROPIC_ADMIN_KEY to an Admin API key (sk-ant-admin...); '
+ 'a workspace key cannot read /v1/organizations/*');
process.exitCode = 2;
return;
}
const days = Number(process.env.DAYS ?? 30);
const start = windowStart(days);
const limit = Math.min(days + 1, 31);
const costs = costByWorkspace(await reportPages(admin, '/organizations/cost_report',
{ starting_at: start, limit, 'group_by[]': ['workspace_id'] }));
const total = Math.round(Object.values(costs).reduce((a, v) => a + v, 0) * 100) / 100;
const share = unattributedShare(costs);
const split = usageSplit(await reportPages(admin,
'/organizations/usage_report/messages',
{ starting_at: start, bucket_width: '1d', limit,
'group_by[]': ['api_key_id', 'workspace_id'] }));
const folded = foldKeys(await listing(admin, '/organizations/api_keys', { limit: 100 }));
console.log(`$${money(total)} in the last ${days} day(s) across `
+ `${Object.keys(costs).length} workspace row(s)`);
console.log(`unattributed: $${money(costs[DEFAULT_WS] ?? 0)} `
+ `(${(share * 100).toFixed(0)}% of spend) has a null workspace_id`);
const plays = playgroundShare(split);
console.log(`usage split of the null bucket: ${((1 - plays) * 100).toFixed(0)}% from `
+ `API keys, ${(plays * 100).toFixed(0)}% Console playground`);
const [state, detail] = verdict(share, total, folded, split);
console.log(`${state.padEnd(18)} ${detail}`);
if (FINDINGS.has(state)) {
for (const kind of MOVABLE) {
for (const key of folded[kind] ?? []) {
console.log(` ${key.id.padEnd(12)} ${key.name.padEnd(22)} ${kind}`);
}
}
for (const line of repairLines(state, folded, split)) {
console.log(` repair: ${line}`);
}
}
process.exitCode = FINDINGS.has(state) ? 1 : 0;
}
if (import.meta.url === `file://${process.argv[1]}`) await main();
Add a test
The first test is the note: a thirty-eight per cent null bucket that is ninety per cent keys, four of them named, and a repair that still states the playground remainder out loud. The second is the same null share with the usage split inverted, and it has to come back as a different state with no migration in it, because those requests never carried a key. After that: the scope resolver, which has to prefer scope.workspace_id over the deprecated top-level field and refuse to classify a scope type it does not recognise; the classification order that stops a playground request in the default workspace being counted twice; the decimal-string amount that a naive sum concatenates; inactive keys, which must never reach a list somebody is going to work through by hand; and the floor below which no share is a finding.
from anthropic_default_workspace_cost import (amount, cost_by_workspace,
fold_keys, key_attribution,
playground_share, repair_lines,
unattributed_share, usage_split,
verdict, weigh)
def cost(workspace_id, value):
return {"workspace_id": workspace_id, "amount": value, "currency": "USD"}
def use(api_key_id, workspace_id, tokens):
return {"api_key_id": api_key_id, "workspace_id": workspace_id,
"uncached_input_tokens": tokens}
def page(results):
return {"data": [{"results": list(results)}], "has_more": False}
def key(kid, name, scope_type="workspace", scope_ws=None, top_ws=None,
status="active"):
return {"id": kid, "name": name, "status": status,
"scope": {"type": scope_type, "workspace_id": scope_ws},
"workspace_id": top_ws}
KEYS = [
key("apikey_01aa", "nightly-summaries", scope_type="organization"),
key("apikey_01bb", "ingest-worker"),
key("apikey_01cc", "eval-runner"),
key("apikey_01dd", "adam-scratch"),
key("apikey_01ee", "billing-team", scope_ws="wrkspc_01"),
]
def test_the_unallocated_bucket_is_two_causes_and_one_of_them_moves():
# The note in one assertion. A large null share, mostly from keys, with
# four named keys to move and a playground remainder that will not budge.
costs = cost_by_workspace([page([cost(None, "15706.09"),
cost("wrkspc_01", "17000.00"),
cost("wrkspc_02", "8502.46")])])
total = round(sum(costs.values()), 2)
share = unattributed_share(costs)
assert round(share, 2) == 0.38
split = usage_split([page([use(None, None, 900_000),
use("apikey_01bb", None, 9_100_000),
use("apikey_01ee", "wrkspc_01", 40_000_000)])])
folded = fold_keys(KEYS)
state, detail = verdict(share, total, folded, split)
assert state == "movable-keys"
assert "4 active key(s)" in detail
repairs = repair_lines(state, folded, split)
assert any("organization scope" in line for line in repairs)
assert any("Console playground" in line for line in repairs)
assert any("rate-limit override" in line for line in repairs)
def test_playground_traffic_has_no_key_to_move():
# Identical cost shape, inverted usage split. Nothing about the keys
# changed and the correct answer did: this bucket has no migration in it.
costs = cost_by_workspace([page([cost(None, "15706.09"),
cost("wrkspc_01", "25502.46")])])
split = usage_split([page([use(None, None, 9_000_000),
use("apikey_01bb", None, 1_000_000)])])
assert round(playground_share(split), 2) == 0.90
state, detail = verdict(unattributed_share(costs),
round(sum(costs.values()), 2), fold_keys(KEYS), split)
assert state == "console-playground"
assert "no key can be moved" in detail
assert not any("recreate each key" in line
for line in repair_lines(state, fold_keys(KEYS), split))
def test_the_scope_resolver_prefers_scope_over_the_deprecated_field():
assert key_attribution(key("k", "n", scope_type="organization")) == \
("organization-scoped", None)
assert key_attribution(key("k", "n", scope_ws="wrkspc_01")) == \
("named-workspace", "wrkspc_01")
# Deprecated top-level field is the fallback, never the first read.
assert key_attribution(key("k", "n", top_ws="wrkspc_09")) == \
("named-workspace", "wrkspc_09")
assert key_attribution(key("k", "n", scope_ws="wrkspc_01",
top_ws="wrkspc_09"))[1] == "wrkspc_01"
# No workspace anywhere: the default workspace, which has no id to report.
assert key_attribution(key("k", "n")) == ("default-workspace", None)
assert key_attribution({}) == ("default-workspace", None)
# An unrecognised scope is never assumed harmless.
assert key_attribution(key("k", "n", scope_type="service_account"))[0] == \
"unknown-scope"
def test_a_playground_request_in_the_default_workspace_is_counted_once():
# Both fields null. Counting it in both buckets inflates the movable half,
# which is the half the script is about to recommend work on.
split = usage_split([page([use(None, None, 1_000)])])
assert split["console-playground"] == 1_000
assert split["default-workspace"] == 0
assert playground_share(split) == 1.0
assert playground_share({}) == 0.0
def test_amount_is_a_decimal_string_and_null_gets_a_sentinel():
assert amount({"amount": "1174.40"}) == 1174.40
assert amount({"amount": None}) == 0.0
assert amount({"amount": "not money"}) == 0.0
assert amount(None) == 0.0
rows = cost_by_workspace([page([cost(None, "10.00"), cost(None, "5.00"),
cost("wrkspc_01", "85.00")])])
assert rows["(default workspace)"] == 15.0
assert round(unattributed_share(rows), 2) == 0.15
assert unattributed_share({}) == 0.0
def test_inactive_keys_never_reach_the_migration_list():
folded = fold_keys(KEYS + [key("apikey_01ff", "retired",
status="inactive"),
key("apikey_01gg", "gone", status="archived")])
ids = [k["id"] for k in folded["default-workspace"]]
assert "apikey_01ff" not in ids and "apikey_01gg" not in ids
assert len(folded["default-workspace"]) == 3
assert len(folded["organization-scoped"]) == 1
assert len(folded["named-workspace"]) == 1
assert fold_keys(None)["default-workspace"] == []
def test_a_small_share_and_an_empty_window_are_never_findings():
costs = cost_by_workspace([page([cost(None, "40.00"),
cost("wrkspc_01", "960.00")])])
state, _ = verdict(unattributed_share(costs), 1000.0, fold_keys(KEYS),
usage_split([]))
assert state == "attributed"
assert verdict(1.0, 0.0, fold_keys([]), {})[0] == "no-spend-yet"
assert repair_lines("attributed", {}, {}) == []
assert weigh({"uncached_input_tokens": 10, "output_tokens": 5,
"cache_creation": {"ephemeral_5m_input_tokens": 7}}) == 22
assert weigh({"cache_creation": 3}) == 0
assert weigh(None) == 0
def test_every_active_key_is_placed_and_the_bucket_still_has_spend():
folded = fold_keys([key("apikey_01ee", "billing-team", scope_ws="wrkspc_01")])
state, detail = verdict(0.31, 41_208.55, folded,
usage_split([page([use("apikey_01ee", None, 5)])]))
assert state == "unattributable-no-key-to-move"
assert "since been deleted" in detail
assert any("do not open a migration ticket" in line
for line in repair_lines(state, folded, {}))
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { amount, costByWorkspace, foldKeys, keyAttribution, playgroundShare,
repairLines, unattributedShare, usageSplit, verdict, weigh, windowStart }
from './anthropic-default-workspace-cost.mjs';
const cost = (workspaceId, value) =>
({ workspace_id: workspaceId, amount: value, currency: 'USD' });
const use = (apiKeyId, workspaceId, tokens) =>
({ api_key_id: apiKeyId, workspace_id: workspaceId, uncached_input_tokens: tokens });
const page = (results) => ({ data: [{ results }], has_more: false });
const key = (id, name, { scopeType = 'workspace', scopeWs = null, topWs = null,
status = 'active' } = {}) =>
({ id, name, status, scope: { type: scopeType, workspace_id: scopeWs },
workspace_id: topWs });
const KEYS = [
key('apikey_01aa', 'nightly-summaries', { scopeType: 'organization' }),
key('apikey_01bb', 'ingest-worker'),
key('apikey_01cc', 'eval-runner'),
key('apikey_01dd', 'adam-scratch'),
key('apikey_01ee', 'billing-team', { scopeWs: 'wrkspc_01' }),
];
test('the unallocated bucket is two causes and one of them moves', () => {
const costs = costByWorkspace([page([cost(null, '15706.09'),
cost('wrkspc_01', '17000.00'),
cost('wrkspc_02', '8502.46')])]);
const total = Math.round(Object.values(costs).reduce((a, v) => a + v, 0) * 100) / 100;
const share = unattributedShare(costs);
assert.equal(Math.round(share * 100) / 100, 0.38);
const split = usageSplit([page([use(null, null, 900000),
use('apikey_01bb', null, 9100000),
use('apikey_01ee', 'wrkspc_01', 40000000)])]);
const folded = foldKeys(KEYS);
const [state, detail] = verdict(share, total, folded, split);
assert.equal(state, 'movable-keys');
assert.match(detail, /4 active key\(s\)/);
const repairs = repairLines(state, folded, split);
assert.ok(repairs.some((l) => l.includes('organization scope')));
assert.ok(repairs.some((l) => l.includes('Console playground')));
assert.ok(repairs.some((l) => l.includes('rate-limit override')));
});
test('playground traffic has no key to move', () => {
const costs = costByWorkspace([page([cost(null, '15706.09'),
cost('wrkspc_01', '25502.46')])]);
const split = usageSplit([page([use(null, null, 9000000),
use('apikey_01bb', null, 1000000)])]);
assert.equal(Math.round(playgroundShare(split) * 100) / 100, 0.9);
const total = Math.round(Object.values(costs).reduce((a, v) => a + v, 0) * 100) / 100;
const [state, detail] = verdict(unattributedShare(costs), total, foldKeys(KEYS), split);
assert.equal(state, 'console-playground');
assert.match(detail, /no key can be moved/);
assert.ok(!repairLines(state, foldKeys(KEYS), split)
.some((l) => l.includes('recreate each key')));
});
test('the scope resolver prefers scope over the deprecated field', () => {
assert.deepEqual(keyAttribution(key('k', 'n', { scopeType: 'organization' })),
['organization-scoped', null]);
assert.deepEqual(keyAttribution(key('k', 'n', { scopeWs: 'wrkspc_01' })),
['named-workspace', 'wrkspc_01']);
assert.deepEqual(keyAttribution(key('k', 'n', { topWs: 'wrkspc_09' })),
['named-workspace', 'wrkspc_09']);
assert.equal(keyAttribution(key('k', 'n', { scopeWs: 'wrkspc_01', topWs: 'wrkspc_09' }))[1],
'wrkspc_01');
assert.deepEqual(keyAttribution(key('k', 'n')), ['default-workspace', null]);
assert.deepEqual(keyAttribution({}), ['default-workspace', null]);
assert.equal(keyAttribution(key('k', 'n', { scopeType: 'service_account' }))[0],
'unknown-scope');
});
test('a playground request in the default workspace is counted once', () => {
const split = usageSplit([page([use(null, null, 1000)])]);
assert.equal(split['console-playground'], 1000);
assert.equal(split['default-workspace'], 0);
assert.equal(playgroundShare(split), 1);
assert.equal(playgroundShare({}), 0);
});
test('amount is a decimal string and null gets a sentinel', () => {
assert.equal(amount({ amount: '1174.40' }), 1174.4);
assert.equal(amount({ amount: null }), 0);
assert.equal(amount({ amount: 'not money' }), 0);
assert.equal(amount(null), 0);
const rows = costByWorkspace([page([cost(null, '10.00'), cost(null, '5.00'),
cost('wrkspc_01', '85.00')])]);
assert.equal(rows['(default workspace)'], 15);
assert.equal(Math.round(unattributedShare(rows) * 100) / 100, 0.15);
assert.equal(unattributedShare({}), 0);
});
test('inactive keys never reach the migration list', () => {
const folded = foldKeys([...KEYS,
key('apikey_01ff', 'retired', { status: 'inactive' }),
key('apikey_01gg', 'gone', { status: 'archived' })]);
const ids = folded['default-workspace'].map((k) => k.id);
assert.ok(!ids.includes('apikey_01ff') && !ids.includes('apikey_01gg'));
assert.equal(folded['default-workspace'].length, 3);
assert.equal(folded['organization-scoped'].length, 1);
assert.equal(folded['named-workspace'].length, 1);
assert.deepEqual(foldKeys(null)['default-workspace'], []);
});
test('a small share and an empty window are never findings', () => {
const costs = costByWorkspace([page([cost(null, '40.00'),
cost('wrkspc_01', '960.00')])]);
assert.equal(verdict(unattributedShare(costs), 1000, foldKeys(KEYS),
usageSplit([]))[0], 'attributed');
assert.equal(verdict(1, 0, foldKeys([]), {})[0], 'no-spend-yet');
assert.deepEqual(repairLines('attributed', {}, {}), []);
assert.equal(weigh({ uncached_input_tokens: 10, output_tokens: 5,
cache_creation: { ephemeral_5m_input_tokens: 7 } }), 22);
assert.equal(weigh({ cache_creation: 3 }), 0);
assert.equal(weigh(null), 0);
});
test('every active key is placed and the bucket still has spend', () => {
const folded = foldKeys([key('apikey_01ee', 'billing-team', { scopeWs: 'wrkspc_01' })]);
const [state, detail] = verdict(0.31, 41208.55, folded,
usageSplit([page([use('apikey_01ee', null, 5)])]));
assert.equal(state, 'unattributable-no-key-to-move');
assert.match(detail, /since been deleted/);
assert.ok(repairLines(state, folded, {})
.some((l) => l.includes('do not open a migration ticket')));
});
test('the window start is floored to midnight utc', () => {
assert.equal(windowStart(30, new Date('2026-08-31T17:45:12Z')),
'2026-08-01T00:00:00Z');
});
FAQ
Is this the same as not being able to attribute cost per customer?
No, and the difference is the point. Per-customer cost is unknowable on both platforms: your tenant is not in the attribution chain, which runs from request to API key to key owner, and nothing you send with a request can put it there. That question has no answer at any threshold. This one has a list of answers. The null workspace bucket decomposes into API keys with ids and names, and every one of them can be recreated inside a named workspace, after which its cost lands somewhere a team can be charged for it. One is a property of the platform and the other is a Tuesday afternoon.
Can I just move an existing key into a workspace?
No. A key's workspace is fixed when it is created, so the repair is to create a replacement key in the named workspace, cut the workload over, and then disable the old one. That is why the script prints the list rather than a single command: this is a migration with as many steps as you have keys, and each step has an owner who has to be told before their job starts failing. Organization-scoped keys are the same story with one extra wrinkle, in that they were never bound to a workspace in the first place.
Why does the script care about Console playground traffic?
Because it is the part of the unallocated bucket that no key migration will ever shrink, and reporting the bucket as one number promises a fix that only works on part of it. Playground requests are not associated with any API key, so they report a null api_key_id even when they also report a null workspace id. If they are the majority of the null usage, the script says so and stops recommending a migration, because the real question is where people should be running experiments.
The default workspace is fine for us. Are we wrong?
Not necessarily, and the script has a threshold rather than a rule. A small organization running everything in the default workspace has a topology fact rather than an attribution failure, and the note about having no boundary at all is the one that applies. What is worth knowing either way is that the default workspace cannot carry a rate-limit override, so traffic there is unbounded relative to the organization limit and can consume the allocation the named workspaces are relying on.
Which field tells me a key is in the default workspace?
Read scope.workspace_id first and the deprecated top-level workspace_id second. For a key bound to the default workspace the deprecated field is null while the scope carries the real id, so a reader that consults only one of them misclassifies keys in both directions. A scope.type of organization means the key was never bound to a workspace at all. The script also refuses to classify a scope type it does not recognise, because an unfamiliar scope showing up in a future API version should appear in the output as unknown rather than be silently filed as safe.
Related field notes
- The attribution question that has no answer at any threshold
- Whether there is more than one container to grant anybody a role in
- A container that stops appearing in the listing and keeps its keys
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.
- Usage and Cost API — Claude Docs
- Get cost report — Claude Admin API
- List API keys — Claude Admin API
- Rate limits API — Claude Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.