Billing and usage
How Better Compute meters reserved time into hourly usage windows, records them on Better IAM meters with poison isolation, and where budgets apply.
Better IAM is compute's billing ledger. Compute measures what each tenant held and for how long, and records it on Better IAM meters. Better IAM prices the usage, tracks budgets and produces invoices. Compute never prices anything itself.
The basis is reserved time: a reservation is billed from the moment it becomes active (its allocation is bound on the node) until it is released or ends, whether or not the GPUs were busy. Usage leaves compute as hourly , each with an idempotency key, so every retry is safe.
Meters
Pricing in Better IAM is per meter, so compute uses one meter per GPU model family and one per other billable unit. All are counted in hours:
| Meter | What accrues it |
|---|---|
gpu.<model>.hours, lower case, such as gpu.h100.hours | GPUs booked by a reservation, and the GPUs of a hosted instance's flavor. Fractional atoms count as fractions of a GPU. |
gpu.<model>.spot.hours | GPUs booked by a spot reservation (v0.5) |
node.metal.hours | A whole-node lease (binding: 'node', v0.5): one per lease hour, on top of its GPUs |
vps.vcpu.hours | A hosted instance's vCPUs |
vps.memory.gib.hours | A hosted instance's memory in GiB |
vps.sandbox.warm | A warm sandbox instance's vCPUs until a sandbox claims it, billed to the tenant with no identity (v0.5) |
A fractional atom bills its share of a GPU: a MIG instance its compute slices over 7 (3g.40gb is 3/7), a
time-slice slot one over the number of slots, and an MPS slot its units over 100. See
Fractional GPUs. Provider-backed instances have no allocation in compute
and are billed by their provider.
Create the meters
Meters live in the root tenant, and Better IAM resolves them for every tenant below it. better-compute iam setup --meters creates the missing ones with a root credential and leaves existing ones alone, so you can run it
again after adding a model:
BETTER_IAM_TOKEN=biam_... better-compute iam setup --audience https://compute.example.com \
--meters --iam-url https://iam.example.com --root-tenant ten_root --gpu-models H100,A100--metersneeds--iam-url(orBETTER_IAM_URL) and--root-tenant(orBETTER_COMPUTE_ROOT_TENANT), andBETTER_IAM_TOKENmust hold a root session token or API key. It calls Better IAM through the optional peer@better-iam/client.- Without
--gpu-modelsit creates a meter for every known family:H100,H200,B200,GB200,A100,L40S,L4,RTX-PRO-6000(gpu.rtx-pro-6000.hours),GH200,L40,A40,A30,A10,RTX-6000-ADA,RTX-A6000,RTX-5090,RTX-5080andRTX-4090, plusnode.metal.hours,vps.vcpu.hours,vps.memory.gib.hoursandvps.sandbox.warm(warm sandbox vCPU hours). - The result lists
meters.createdandmeters.existing. No prices are set: pricing is your decision, made with Better IAM'sbilling.setPrice.
The same steps are exported for code:
import { computeMeters, setupIamMeters } from 'better-compute/iam';
const { created, existing } = await setupIamMeters(
iam.api,
{ credential: rootCredential, tenantId: rootTenantId },
computeMeters(['H100', 'A100']),
);Usage windows
The capacity.usage job (every 5 minutes; capacity: { usageEvery } changes it) and the vps.usage job (every
5 minutes) write a window per allocation, meter and clock hour into usage_windows:
CREATE TABLE usage_windows (
idempotency_key text PRIMARY KEY, -- cmp:<allocationId>:<windowStartMs>
allocation_id text NOT NULL REFERENCES allocations,
tenant_id text NOT NULL,
identity_id text, -- NULL once orphaned (tenant-attributed)
meter text NOT NULL,
quantity numeric NOT NULL CHECK (quantity >= 0),
window_start timestamptz NOT NULL,
window_end timestamptz NOT NULL,
tags jsonb NOT NULL DEFAULT '{}', -- region, pool, gpuModel, project, agent
recorded_at timestamptz,
outcome text CHECK (outcome IN ('recorded', 'duplicate', 'reattributed', 'dead-lettered')),
CHECK (window_end > window_start),
UNIQUE (allocation_id, window_start, meter)
);- Windows are aligned to the clock hour. The quantity is the meter's rate per hour times the fraction of the hour used, rounded to six decimals: 4 GPUs held from 10:15 bill 3 GPU hours for the 10:00 window.
- A window is written only when it is final: its hour has closed, or billing ended inside it. It never changes
afterwards, and
ON CONFLICT DO NOTHINGmakes every pass safe to repeat. Each pass looks back 48 hours. - The idempotency key is
cmp:<allocationId>:<windowStartMs>. An allocation that bills a second meter in the same window (a node lease's GPUs) appends:<meter>to the second key. - Tags carry only bounded values:
region,pool,gpuModel,projectand, for delegated work,agent. Never a reservation, allocation or node id.
A node lease bills node.metal.hours plus only the GPUs that were healthy at the start of each window. The
others are booked only to keep other tenants off the node, and the reservation shows an UnhealthyAtoms
condition for them.
Attribution
Each window names the identity it bills: the reservation's or instance's owner. For work an AI agent does on a
person's behalf, that is the person, and the window carries the tag agent=<agentId>. For an agent using its own
key, the owner is the agent itself. In Better IAM's usage reports, agent-key usage therefore appears under
groupBy: 'agent', while delegated usage appears only under groupBy: 'tag:agent', because Better IAM's
UsageInput has no agentId for delegated work (upstream change U9 would add it).
The usage sink
The iam.usage reconciler of the iam module runs every hour, ahead of Better IAM's daily period close. Each
pass takes up to 1000 closed, unrecorded windows, sends them through IamPort.recordUsage, and stores each
outcome on its window:
| Outcome | Meaning |
|---|---|
recorded | Better IAM recorded the event |
duplicate | The idempotency key was recorded before; nothing changed |
reattributed | The identity no longer exists: the event was recorded under its successor, or for the tenant alone |
dead-lettered | The event still failed on its own; it is stored in usage_dead_letters |
Embedded mode sends batches of 200 events per billing.recordMany (usageBatchSize on bridge.embedded()
takes 1 to 1000), which bounds how long one call holds Better IAM's lock. Remote mode sends at most 100 per HTTP
call, as the root service account.
Poison isolation
Better IAM records a recordMany batch in one transaction, so a single bad event would fail its whole batch on
every retry. The sink isolates it:
- Event-specific errors are
NOT_FOUND,CONFLICT,BILLING_PERIOD_CLOSED,INVALID_INPUT,INVALID_TRANSITIONandMETER_ARCHIVED. Anything else, such as Better IAM being down or a lock timeout, is transient: the whole call rejects, the windows stay unrecorded, and the next pass sends them again with the same keys. So is Better IAM'sNOT_FOUND"Endpoint not found": it means the route is wrong (a misconfigured base path, a proxy), every event would fail alike, and dead-lettering them all would lose the usage. - An identity deleted or moved before the flush (
NOT_FOUND"Identity not found") is re-recorded under the successor named by Better IAM'sidentity:offboardevent, which the IAM inbox stores iniam_successorsand follows for up to 8 hops. Without a successor, the event is recorded with no identity and the tagattribution=orphaned, and the window'sidentity_idis cleared. CONFLICTmeans an idempotency key was reused on another meter, which points to a compute bug.BILLING_PERIOD_CLOSEDmeans the period was invoiced before the window arrived. Both are dead-lettered.
Dead letters
CREATE TABLE usage_dead_letters (
idempotency_key text PRIMARY KEY,
tenant_id text NOT NULL,
event jsonb NOT NULL, -- the UsageEvent as last sent
error_code text NOT NULL,
error_message text NOT NULL,
attempts int NOT NULL DEFAULT 1 CHECK (attempts > 0),
first_failed_at timestamptz NOT NULL DEFAULT compute_now(),
last_failed_at timestamptz NOT NULL DEFAULT compute_now(),
resolved_at timestamptz
);Nothing re-sends a dead letter automatically: its window is marked dead-lettered and the sink moves on. Fix the
cause (create the missing meter, for example) and record the event yourself with its stored idempotency key. For
a closed period, PLAN.md suggests a root billing.createInvoiceItem instead.
Read usage
usage.list returns a tenant's windows as the tenant can read them, with each window's outcome:
const page = await compute.api.usage.list(credential, {
tenantId,
projectId, // optional: decides compute:usage:read on the project instead of the tenant
meter: 'gpu.h100.hours',
from: Date.parse('2026-09-01T00:00:00Z'), // windows that end after this
to: Date.parse('2026-10-01T00:00:00Z'), // windows that start before this
limit: 500, // 100 by default
});
// page.items[0]: { idempotencyKey, allocationId, tenantId, identityId?, meter, quantity,
// windowStart, windowEnd, tags, recordedAt?, outcome? }It decides compute:usage:read on compute-tenant/{t}, or on compute-project/{id} when the input names a
project. Prices and invoices come from Better IAM's own billing API.
Export usage
usage.export (v0.2) returns every window of a range in one response, for a
billing export, with the same decision as usage.list:
const exported = await compute.api.usage.export(credential, {
tenantId,
projectId, // optional, as for usage.list
meter: 'gpu.h100.hours', // optional
from: Date.parse('2026-09-01T00:00:00Z'),
to: Date.parse('2026-10-01T00:00:00Z'), // after from, at most 93 days later
format: 'csv', // or 'jsonl'
});
// { format, data, count, totals: { 'gpu.h100.hours': 812 }, truncated }csv(the default) is RFC 4180 CSV with a header row:idempotencyKey,allocationId,tenantId,identityId,meter,quantity,windowStart,windowEnd,tags,recordedAt,outcome, times as ISO 8601 andtagsas JSON.jsonlis one JSON window per line.totalssumsquantityper meter over the exported windows.- At most 50,000 windows, ordered by start:
truncated: truemeans more matched, so narrow the range or the meter.
Budgets
PLAN.md stops spending in three layers, and all three work today:
- In the decision. The
StopWhenOverBudgetstatement of the gpu-user policy denies new reservations, extensions and instances onceprincipal.spendExceededis true, meaning an enforced Better IAM budget covering the caller is spent. It costs nothing extra: Better IAM evaluates it inside the one decision. It also namescompute:sandboxes:createandcompute:sandboxes:update, so new sandboxes and sandbox extensions stop too. - Per pool, before admission.
reservations.create,reservations.extend,instances.create,sandboxes.create(on the template's pool) andsandboxes.extend(on the sandbox's pool; f3b7f9e) run capacity'sbudgetAdmissionCheckas theirMethodAdmission.check, after the IAM decision with no transaction open. It calls Better IAM'sbilling.checkthroughIamPort.billingCheck({ tenantId, identityId }, credential)when the pool's policy setsadmission.budgetCheck: 'meter', or when the tenant's offer hasonBudgetExceeded: 'stop-new'or'drain'; otherwise it costs nothing. A spent budget refuses withSPEND_LIMIT_REACHED(402), with the budget'sbudgetIdandnameindetails. A refused create keeps its draft, so the same idempotency key is decided again once the budget's window resets; a refused extend clears the pending change. In remote mode the check runs as the caller, because Better IAM's HTTPbilling.checkreports the credential's own spend in its own tenant. - On a budget alert. Better IAM's
billing:budget-alertevent reaches the IAM inbox'sbudgetreaction.budgetAlertHandlerreads the alert (budgetAlertOf: budget,actualorforecast, threshold, spent, amount, window) and acts only on an exceeded budget, anactualalert at 100% or more.serve's default handler then applies capacity'sapplyBudgetPolicy: every offer to the tenant with anonBudgetExceededpublishesquota.budget-exceeded, anddrainalso cancels the tenant's unstarted reservations in that pool.stop-newanddrainalso refuse new reservations and instances through the per-pool check above. Offers inherited throughincludeDescendantsare not covered, because compute does not know the tenant tree: offer the budgeted tenant directly.
With runIamJobs: true, the iam module also runs Better IAM's budget checks every hour and its period close
every day.
Better Compute is created by Sean Filimon
Last updated
Tenancy
How Better Compute maps onto Better IAM tenants, with the root tenant as the platform, pools offered to organizations, and projects inside a tenant.
Delegated confirmationnew
How an AI agent acting for a person gets CONFIRMATION_REQUIRED instead of a denial, how the draft waits for approval, and how the retry completes.