How do I cap a run's spend?¶
You want a number you can set that the run genuinely cannot go past — not a dashboard you read afterwards, and not an alert that arrives once the money is gone.
When you'd want this¶
Any run that talks to a real LLM can, in principle, overspend. The answer isn't "watch a dashboard" — it's a hard, in-loop ceiling that halts the run when it's crossed. agentkit ships two meters that share one Protocol:
Budget— per-run cost / call ceiling. One instance per run.Quota— per-tenant rolling window (RPM, TPM, USD/window), keyed byScope.key(). One shared instance across all runs of a tenant.
Both hook in through the same meter() middleware in the chat chain.
By default overspend raises MeterExceeded — the invoker unwinds and
the loop halts. With on_exceeded="stop" it returns a verdict
instead, and the run stops after writing a checkpoint, so the spend
is recoverable. See
Making exhaustion recoverable.
Money is Decimal, not float. Binary floating point cannot
represent 0.01, so a hundred one-cent charges summed as floats land
at 1.0000000000000007 and a metered run cannot be reconciled to the
cent. Budget keeps an exact ledger — read it with budget.spent()
(Decimal) or budget.spent_cents() for invoicing.
budget.spent_usd is still there and is a float mirror of the
ledger, kept in sync after every charge: fine for display, not for
summing.
Assumes ANTHROPIC_API_KEY in the environment
Wired via providers.claude(...) on the Invoker. Swap for
providers.openai (and set OPENAI_API_KEY) if that's what you
have — the rest of the wiring is unchanged.
To run it with no key at all, replace the providers.claude(...)
call with FakeLLM("a short answer") from agentkit.core.testing. Every
other line is identical — that substitution is how this snippet is
verified.
Working code¶
"""Requires ANTHROPIC_API_KEY in the environment.
Demonstrates the halt path with `max_calls=1` — a `max_cost_usd`
ceiling is set generously so cost is not what stops the loop; the
per-call ceiling reliably trips on the second `invoker.chat(...)`."""
import asyncio
import os
from agentkit.core.runtime import Budget, MeterExceeded, Quota
from agentkit.core.contracts.types import ChatRequest, Message, Scope
from agentkit.models.providers import frontiers as providers
from agentkit.core.policies import meter
from agentkit.core.runtime import Invoker, RunContext, Services
async def main() -> None:
llm = providers.claude(
api_key=os.environ["ANTHROPIC_API_KEY"],
model="claude-sonnet-4-6",
)
services = Services(invoker=Invoker(llm=llm, chat_middleware=[meter()]))
ctx = RunContext(
correlation_id="run-1",
scope=Scope(org_id="acme", domain_id="research"),
budget=Budget(max_cost_usd=0.10, max_calls=1), # halts on the second call
meters=[Quota(max_rpm=3)], # extra per-tenant window
services=services,
)
req = ChatRequest(
messages=[Message("user", "One short sentence about octopus cognition.")],
model="claude-sonnet-4-6",
)
for i in range(1, 6):
try:
await ctx.invoker.chat(req, ctx)
print(f"call {i}: OK spent=${ctx.budget.spent_usd:.4f}")
except MeterExceeded as exc:
print(f"call {i}: STOP {exc}")
break
if __name__ == "__main__":
asyncio.run(main())
How it works¶
Two questions get asked around every call: are you allowed to do this?
before it runs, and here is what it actually cost after. The first is
guard, the second is charge, and the meter() middleware is what
asks them.
Splitting it that way is what makes the accounting honest. Before a call you can only estimate; after it you know. So the ceiling is checked up front, and the ledger is written from measured reality — which does mean a call can finish and then tip you over. That is a deliberate trade, and the paragraph after next spells out exactly where the off-by-one lands.
The meter() middleware calls guard(...) on every entry in
ctx.all_meters before the invoker runs the call, and charge(...)
after. Both hit an async lock so totals are invariant under concurrent
workers.
Budget._verdict uses strict greater-than (spent > max) — a call
whose cost lands exactly on the ceiling completes; the next one trips
MeterExceeded. The same applies to max_calls, and note WHICH hook
trips: guard runs before the work, charge after, and both raise on
a bad verdict. With max_calls=1 the first call's guard sees
calls == 0, the work runs, and charge bumps to 1 — 1 > 1 is
false, so it completes. The second call's guard still sees 1 > 1
false and lets the work run; its charge bumps to 2 and raises
there. So max_calls=1 stops the loop on the second iteration, from
charge — the ceiling is enforced one call late by construction, which
is the cost of counting completed work rather than intentions.
Budget is also the run's depth and concurrency authority. max_depth
caps how deep the agent tree can spawn. max_concurrency (default 8)
caps how many things run at once — per level of the tree, not across
the whole tree. That per-level detail is not a technicality: one
shared pool for the whole tree deadlocks, because a parent holds its
permit for as long as its children run. See
Runtime
for the full picture; the practical consequence is that worst-case
in-flight work is max_concurrency * (max_depth + 1), so size it with
that in mind.
Quota is a rolling window keyed by Scope.key(). It counts requests
made (not just completed), so a burst that overshoots RPM is caught on
the way in, not after the fact. The in-memory implementation is fine
for a single process; a multi-process deployment wants a Redis-backed
Quota (write your own Meter impl behind the same Protocol).
Making exhaustion recoverable¶
The default on_exceeded="raise" has a sharp edge worth knowing
about. MeterExceeded is raised from inside charge(), which runs in
the meter middleware's on_response, which is inside
ctx.invoker.stream(...) — so it unwinds past every checkpoint
write in the loop. A tool-loop run that crosses its ceiling on the
second turn aborts holding the first turn's checkpoint: marked
running rather than suspended, and recording only half the money
that actually left the account.
on_exceeded="stop" fixes that. The meter records the spend and
returns a Charge verdict; control reaches the cognition, which still
holds the live context and writes a current suspended checkpoint
before ending the run:
budget = Budget(max_cost_usd="1.00", on_exceeded="stop")
result = await agent.run(task, ctx)
if result.stop_reason == "budget_exhausted":
assert result.is_resumable # a checkpoint exists, and it's current
raise_the_ceiling_and_requeue(ctx.correlation_id)
Both cognitions also pre-flight the budget at the top of each
iteration, so retrying a run whose ceiling nobody raised costs nothing
— it stops before making a call rather than after. And
budget.max_cost_usd = 10.0 after construction really does raise the
ceiling; the normalised value re-derives on assignment.
Raising is still the default, deliberately. Flipping it would silently change control flow in every existing wiring: a run that used to abort would continue past its ceiling in any caller that ignores the return value — a worse failure than the one being fixed.
The verdict itself is useful even when you keep raising:
verdict = await budget.charge(call, usage)
verdict.ok # False when a ceiling was crossed
verdict.reason # "cost $1.2 > $1"
verdict.spent # Decimal — exact (Budget.spent_usd is the float mirror)
verdict.remaining # Decimal | None
verdict.usage # cumulative Usage — input/output/cache tokens, not just cost
verdict.raise_if_exceeded() # back to the old control flow, at one site
Token counts, not just cost¶
Budget.usage accumulates the whole Usage — input, output,
cache-read and cache-write tokens. Because Budget is shared by
reference across ctx.child(), a whole agent tree rolls up into
one object:
budget.usage.input_tokens # across every agent in the tree
budget.usage.cache_read_tokens # cache effectiveness, for free
budget.usage.total_tokens
Applications used to re-aggregate this from spans or from their own callbacks. They no longer need to.
Per-agent budgets¶
For multi-agent runs, ActorBudget gives each child agent its own
four-axis envelope (tokens, cost_usd, steps, wall_seconds) with
reservation accounting. When run_agents(...) fans children out under
a parent's ActorBudget, each child gets a 1/N slice reserved before
dispatch; overspend on one child can't drain the parent's envelope
because settle_child(...) caps at the reservation.
from agentkit.agents.control.budget import ActorBudget
ctx.actor_budget = ActorBudget(
max_tokens=100_000, max_cost_usd="5.00", max_steps=50, max_wall_seconds=600,
)
The envelope is charged by the same meter() middleware that charges the
run Budget — one model call is one step, plus its tokens and cost. Its
cost axis is an exact Decimal ledger like Budget's; used_cost() /
remaining_cost() are the exact reads and the *_usd attributes are float
mirrors for display.
Unlike Budget, there is no on_exceeded switch: ActorBudget.charge
never raises, so an in-flight call always completes, and the cognition's
next pre-flight stops the loop with stop_reason="budget_exhausted" naming
the axis that ran out. A token-out is a "wind down" signal; a wall-out may
warrant a hard cancel, and the reason string tells you which.
BudgetExhausted (distinct from Budget's MeterExceeded) is raised when a
fan-out cannot be carved — fail-fast, before any child runs, on any of the
three reservation axes:
- the parent envelope is already exhausted, or
- a slice would round to nothing (N children against fewer than N tokens, steps, or micro-dollars).
Both cases used to produce N children that each stopped on their first check —
a fan-out that looked like it ran and did nothing. The exception names the
axis. Slices are equal (so reservation order cannot skew fairness) and the
money axis is carved in Decimal, so nothing is lost to float rounding; each
child is granted exactly what was reserved for it and no more.
What bites people¶
meter()is not on by default. You wire it intoInvoker(chat_middleware=[..., meter(), ...]). Put it aftertracing()and before caching / retry so the meter observes every attempted call.Budget.max_cost_usd=Noneis unlimited. Set bothmax_cost_usdandmax_callsunless you have a reason to leave one open.Quotacounts by attempted request, not by success. A tenant that keeps triggering timeouts still burns RPM. This is deliberate — a naive "count on success" surface lets a broken client hammer the provider.MeterExceededvsBudgetExhaustedare two different signals.MeterExceeded(fromagentkit.core.runtime.meter) is the per-run ceiling;BudgetExhausted(fromagentkit.agents.control.budget) is the per-actorActorBudgetsignal. Catch each where it fires.- A budget is always overrun by at most one call's cost.
_checkcomparesspent > ceilingafter the work has run; there is no pre-flight estimate. Set the ceiling slightly below your true limit, and useon_exceeded="stop"so the overshoot is recoverable rather than fatal. -
An unpriced model costs
$0.00.pricing.cost()returns zero for a model it doesn't know, so a ceiling never fires and the run is effectively unbounded.Registering the model in the model registry does not fix this — that table routes names to providers and declares capabilities, and
ModelEntryhas no price field. The price table is separate. Pass your ownpricing=callable to the provider instead:def my_pricing(model: str, usage: Usage) -> float: rate_in, rate_out = MY_RATES[model] # $ per 1M tokens return (usage.input_tokens * rate_in + usage.output_tokens * rate_out) / 1e6 llm = claude(api_key=..., pricing=my_pricing)It takes
(model, usage)and returns dollars;None(the default) uses the bundled table. If you meter spend, assume the bundled table is stale for anything recent and supply your own. - An over-precise ceiling is refused; an over-precise charge is quantized.Budget(max_cost_usd="0.0000001")raisesMoneyPrecisionErrorat construction, because a ceiling is your stated intent and rounding it changes what you asked for. A charge is a measurement, so it is recorded at 6dp rather than aborting a run mid-flight. - Quantize at read, not per charge.budget.spent_cents()rounds once, at the end. Rounding each charge to cents would round every sub-cent call to zero and undercount the whole run.
Related¶
- Tutorial · Step 4 — the same primitive introduced inside the walkthrough.
- Concepts · Runtime — where
Budget/Quota/Metersit alongsideRunContextandInvoker. - Parallel agents with cancellation
— the concurrency surface
Budget.semaphore()bounds.