Skip to content
Chapter 3 of 6 · Denial-of-walletIn-flight budget counters

17 min read

In-flight budget counters

On this page

Detection for denial-of-wallet — the failure in which nothing bounds what a single task or account may spend — has an unusual constraint. The quantity you care about is money, the authoritative record of money is the provider's billing data, and that data does not exist yet while the spending is happening. Every mechanism built on top of it inherits the delay: a usage API that reflects the last hour, a cost export that lands in a warehouse overnight, a budget alert on a cloud console, the invoice. All of them are excellent for reconstructing what happened and none of them can interrupt it.

This chapter is about the signals that are available inside a running task, in your own process, with no external dependency: the number of iterations the loop has taken, the tokens it has accumulated, how long it has been running, and a euro figure derived from a price table you maintain. It covers where those counters have to sit, what to check before a call rather than after it, and what a task should do when one of them trips, which is a question with more than two answers.

Nothing downstream of billing arrives in time

Here is when each part of an organisation could, in principle, have learned about a compounding run — an agent loop whose transcript grows every turn, so the cost per turn rises as it goes — that ended up spending €365.

ElapsedWhat was trueWhat could see it
0:00loop startsthe loop
0:22iteration 25, €0.31 spentthe loop
2:10cumulative input passes 2M tokens, €6.10 spentthe loop, and metrics if they were emitted
4:30first metrics scrape carrying the anomalyPrometheus
7:15alert rule satisfies its 3-minute for: clauseon-call pager
11:04loop terminates on its own, €365 spentnobody in particular
38:00provider usage API reflects the houra dashboard nobody was watching
~6 hcost export lands in the warehouseFinOps queries
9 daysmonth-to-date rollup shows the anomalythe FinOps analyst
22 daysinvoiceeveryone

The pager row is the fastest external mechanism in the list, and its time is not observed — it is computed from configuration you already have, which means you can work out your own figure without waiting for an incident. Metrics are pulled on a fixed cycle, so the anomaly waits up to one scrape interval before it is visible anywhere outside the process. The alerting rule is evaluated on its own cycle, so it waits again. The for: clause then requires the condition to hold for three consecutive minutes before the alert is considered firing, which exists to suppress flapping and is a deliberate delay rather than an accident. So the alert's time is the moment the condition became true plus a scrape interval plus a rule-evaluation interval plus the suppression clause, and every one of those terms is a value in a YAML file somebody chose. Sum them for the intervals this platform ran and the total is four to seven minutes, which is what puts the pager at 7:15 for an anomaly that became visible around the two-minute mark. None of those terms is adjustable to zero without giving up the property it was set for.

Against a run that terminates at 11:04, an alert that exists at 7:15 leaves under four minutes of useful life, and that is the whole of what the fastest external mechanism buys. Those four minutes have to contain a human being woken, oriented, given the task id, and put in front of something that can stop it — and they are the four minutes in which this loop's per-turn cost is the highest it has been all run, because the transcript is at its largest. The on-call rotation was never a control on this failure. It was a notification that arrives while the last and most expensive turns are being billed. That lag is also why FinOps, the joint finance-and-engineering practice that reconciles vendor spend against budgets, cannot be the thing that stops a runaway: it works on data that only exists once the money is gone.

Four counters, and which one moves first

There are four quantities worth carrying inside a task, and they differ in when they cross a threshold you could defend in a review.

SignalAvailable whereThreshold vs normalFires atFails to catch
Iteration countin the loop, before any call25 vs p99 of 90:22one enormous single call
Cumulative tokensusage on each response2M vs p99 of 210k2:10tool charges, model price differences
Wall-clock durationin the loop5 min vs p99 of 40 s5:00fast expensive runs; fires on slow cheap ones
Estimated eurosderived, needs a price table€5 vs p99 of €0.701:50drifts when prices or routing change

The column that decides the ordering is not "fires at" but the one beside it, and the way to read it is as a ratio rather than as two numbers. Each threshold is some multiple of what normal traffic reaches: the iteration ceiling of 25 sits at under three times a p99 of 9, the token ceiling of 2M at more than nine times a p99 of 210k, the euro ceiling of €5 at over seven times a p99 of €0.70, and the five-minute ceiling at about seven and a half times a p99 of 40 s. A runaway pushes all four counters up together, so the one that crosses first is the one whose ceiling is nearest to normal in proportional terms, and that is the iteration count by a factor of roughly three against every other row. The ordering is a property of where the thresholds were placed, not of the counters themselves — which also means it is a property you can restore deliberately if you ever want a different counter to fire first.

Iteration count crosses first, and the reasons are structural rather than lucky. It is an integer that increments in your own process before the model call it precedes, so it costs nothing to maintain and is available even when the provider returns an error, streams a partial response, or omits the usage block. It has the tightest normal distribution of the four, because a healthy agent's step count is bounded by the shape of the task rather than by the size of the data: a run that legitimately needs many steps is rare, so a threshold at 25 sits at roughly 2.8 times the p99 and will not fire on real work. And it needs no pricing information at all, which means it does not silently become wrong when your routing layer starts sending 30% of traffic to a different model tier.

Cumulative tokens is the necessary partner, because iteration count is blind in exactly one direction. A single call carrying 900,000 input tokens costs €2.70 and happens at iteration 1, so a loop counter watching for 25 iterations will never see it. The token counter catches that case and also gives the cheapest defence against the quadratic growth of a compounding transcript, since the growth shows up as an accelerating token total long before it shows up as anything else.

Wall clock is worth carrying but is a liveness signal that correlates loosely with cost. It fires on a run stuck waiting for a tool vendor's slow endpoint, which is cheap and merely annoying, and it fails to fire on a run that spends €40 in ninety seconds by fanning out concurrently. Use it to bound how long a user waits and to stop zombie tasks; do not use it as your cost ceiling.

The estimated euro figure is the one that lets you talk to anyone outside engineering, and it is the one that rots. It requires a price table in your code, a mapping from model identifier to input, cached-input and output prices, plus the per-call charges for tools. That table is correct on the day you write it and wrong the first time a provider adjusts prices or you add a model. Keeping it honest is a reconciliation job: the per-request cost attribution described in topic 2.16, on tracking cost per request, already produces an authoritative figure once the provider's data lands, so compare the in-flight estimate to it weekly and alert when the aggregate drift exceeds a few percent. On a system I worked on, the estimate ran 3.8% under the invoice, which is fine for a ceiling and not fine for anything customer-facing.

The counters have to live in the loop

The temptation is to build this in the observability stack, because the metrics are already being emitted and Grafana already has alerting. The timeline above shows the problem: a scrape interval of 30 seconds in Prometheus, which is the metrics system that pulls counters out of your services on a fixed cycle instead of receiving them as they happen, then a rule evaluation interval of 30 seconds, and a for: 3m clause to suppress flapping add up to four to seven minutes before an alert exists, and then a human has to act on it. During those minutes a quadratic cost curve is still integrating, and the marginal rate is higher at the end than at the start.

So there are two tiers with different jobs. Metrics and alerting tell humans about the fleet — that runaway rates are up, that a particular tenant is unusual, that a deploy shifted the distribution. Enforcement happens synchronously inside the task, on a budget object that the loop consults before each call and updates after it.

python
from dataclasses import dataclass, field
import time

class BudgetExceeded(Exception):
    def __init__(self, limit_name, value, limit):
        self.limit_name, self.value, self.limit = limit_name, value, limit
        super().__init__(f"{limit_name}: {value} exceeds {limit}")

@dataclass
class TaskBudget:
    task_id: str
    max_iterations: int = 25
    max_input_tokens: int = 2_000_000
    max_wall_seconds: float = 300.0
    max_eur: float = 5.00
    iterations: int = 0
    input_tokens: int = 0
    output_tokens: int = 0
    eur: float = 0.0
    started: float = field(default_factory=time.monotonic)

    def preflight(self, next_call_input_tokens: int) -> None:
        """Called before the model call, so the breaching call is never paid for."""
        checks = [
            ("iterations", self.iterations + 1, self.max_iterations),
            ("input_tokens", self.input_tokens + next_call_input_tokens,
             self.max_input_tokens),
            ("wall_seconds", time.monotonic() - self.started, self.max_wall_seconds),
            ("eur", self.eur, self.max_eur),
        ]
        for name, value, limit in checks:
            if value > limit:
                raise BudgetExceeded(name, round(value, 3), limit)

    def settle(self, usage, tier: str, tool_charges_eur: float = 0.0) -> None:
        self.iterations += 1
        self.input_tokens += usage.input_tokens + usage.cache_read_input_tokens
        self.output_tokens += usage.output_tokens
        self.eur += price_of(usage, tier) + tool_charges_eur

Two details in that class carry most of its value. The check runs before the call and takes an estimate of the next call's input size, which for a transcript-based agent is just the token count of the messages you are about to send, so the call that would have breached the ceiling is never billed. Checking after the fact means you always pay for one more call than your limit says, which is harmless at 25 iterations and expensive when the breaching call is the 200,000-token one.

The other detail is that settle records tool charges alongside tokens, in the same euro accumulator. A search tool at €0.005 per query and a sandbox at €0.0004 per CPU-second do not appear in any usage block, and a run that makes 31 searches and 4 code executions has spent €0.16 that a token-only budget cannot see. That is small here and is not small for agents whose tools include paid data providers.

Look closely at the four entries in checks and they are not all built the same way, which is deliberate and worth understanding before someone "fixes" the inconsistency. Two of them project: iterations is compared as the count plus one, and input_tokens as the running total plus the size of the call about to be made. Two of them do not: wall-clock is read as it stands, and eur is compared as the euros already accumulated. The dividing line is whether the quantity is knowable before the call returns. You can count the tokens of a prompt you are holding, and you can add one to an integer. You cannot know the euro cost of a call that has not happened, because a large part of it is the output, and the output length is the model's decision. So the euro check is necessarily one call behind, and the token check is the one that carries the weight on a call whose input is enormous. That is the practical reason both exist rather than just the euro one, even though euros are what anybody outside engineering wants to hear about.

The cost of getting that ordering wrong is not uniform across the run either. Checking after the call rather than before means you always pay for one call more than the ceiling permits, and on a compounding transcript the extra call is the last one, which is the largest and most expensive of the whole run by construction. A limit expressed in iterations overshoots by the priciest turn; a limit expressed in cumulative tokens overshoots by whatever the next call happened to carry, which on a task that begins with a huge document can be most of the budget in one go. Pre-flight checking is not a refinement here. It is what makes the ceiling mean the number written on it.

Wiring it into the loop is three lines:

python
budget = TaskBudget(task_id=task_id)
while True:
    budget.preflight(count_tokens(messages))
    resp = client.messages.create(model=MODEL, max_tokens=4096,
                                  tools=TOOLS, messages=messages)
    budget.settle(resp.usage, tier=tier_of(MODEL))
    messages.append({"role": "assistant", "content": resp.content})
    if resp.stop_reason != "tool_use":
        break
    for call in tool_calls(resp):
        result, charge = run_tool(call)
        budget.eur += charge
        messages.append(tool_result(call, result))

Counting tokens you have not sent yet

The pre-flight check needs the size of a request that has not been made, and there are three ways to get it with different costs. The provider's token-counting endpoint is exact and free of charge, but it is a network round trip of roughly 40 ms against a service that has its own rate limits, and calling it before every model call in a 200-turn loop adds eight seconds of latency to a task and a second dependency to your enforcement path. A local tokeniser for the model family — the same text-splitting routine that produces the units the provider bills you for, run inside your own process — is exact for text and fast, at the price of keeping the vocabulary files in your image and getting images, PDFs and tool schemas wrong. The cheap approximation is characters divided by 3.6 for English prose, which on a mixed corpus of contracts, code and chat transcripts ran within 7% of the true count in both directions.

Seven percent is fine for a ceiling and would be unacceptable for a bill, which is the general principle for the whole enforcement path: the estimate only has to be good enough that the decision it drives is the same one the exact number would have driven. Set the ceiling using the approximation and leave a margin — if the token cap is 2M, a 7% error moves the trip point between 1.86M and 2.14M, and no sensible cap is chosen so finely that this matters. Where it does matter is the single huge call, since a 900,000-token request estimated at 840,000 slips under a 850,000 limit, so it is worth calling the exact counter on the first call of a task, when the context is large and the loop has not started spinning, and using the approximation for the rest.

The general rule underneath those two sentences is a comparison, and it is the one to apply when someone asks how accurate an estimator has to be. Compare the estimator's error against the headroom it has to fit inside. When a ceiling is reached by accumulation across many calls, one call's error is a small fraction of one increment, so a systematic error of a few percent moves the crossing point by a call or two in either direction and the ceiling still binds where you meant it to. When a ceiling is reached by a single item, the same percentage is a percentage of the entire item, so the error has to be smaller than the gap between that item's true size and the cap — otherwise the item passes the check and is then billed in full. Same estimator, same error rate, two regimes with completely different requirements, and the only thing that changed is whether the budget is consumed in many small steps or in one.

Track the estimator's error as a metric of its own. Comparing the pre-flight estimate against the input_tokens the provider reports on the same call gives you a per-call residual for free, and a drift in that residual is an early sign that something upstream changed — a new document parser emitting different whitespace, a tool schema that grew, a model family with a different tokeniser.

Between fine and dead

Raising an exception and letting it propagate is the wrong default, because the four counters do not all mean the same thing and the response should not be binary. Treating a budget as a set of graded states, in the way a circuit breaker — the wrapper that stops calling a dependency it has watched fail, and lets a trial request through later to see whether it has recovered — moves through closed, half-open and open rather than flipping a boolean, gives you somewhere useful to stand.

State diagram: start → Normal; Normal → Warn (60% of any limit); Warn → Constrained (85% of any limit); Constrained → Halted (100% of any limit); Warn → Normal (task completes); Constrained → Normal (task completes); Halted → Queued (checkpoint written); Halted → Killed (no checkpoint possible); Queued → start; Killed → start.

Warn emits an event with the task id, the tenant and the counter that is climbing, and changes nothing about execution. Constrained degrades: route the next calls to the cheaper model tier, disable the tools with per-call charges, force a transcript compaction so the context stops growing, and drop max_tokens. Halted stops issuing calls.

The item in that list that does the structural work is the compaction, and the reason is the shape of the curve rather than the size of the saving. Everything else on the list is a multiplier: a cheaper tier divides the price per token, and disabling paid tools removes a line from the bill. Compaction changes the exponent. Before it, each turn adds to the transcript and the next turn pays for the addition, so the remaining headroom is consumed faster the closer you get to the ceiling — the last stretch of a budget is always the quickest to disappear on a compounding run. After it, the transcript stops growing, and the remaining headroom is consumed at a flat rate. That is what "buys minutes" actually means here: the ceiling did not move and the run was not made cheap, but the approach to the ceiling stopped accelerating, which is the difference between a human having time to look and a human being told afterwards. Degrading at 85% rather than at 100% is the design decision that makes it possible, and its price is that some tasks finish on the small model when they did not have to.

Queueing is a scheduling decision, not a stop

The distinction to hold on to is that a queue can only ever defer. Queue machinery has its own failure modes, and a task that is supposed to pause but instead retries is worse than one that dies, so the hard kill stays wired regardless — with a supervisor that terminates the task if it is still issuing calls thirty seconds after Halted. A design where the only stop is a scheduling decision has no stop at all on the day the scheduler is the thing that is broken.

In-flight numbers are not attribution

Confusing in-flight enforcement with cost attribution produces a euro figure nobody can reconcile. The attribution layer — per request, per user, per feature, per tenant — is authoritative, arrives late, and is what finance uses. The budget object is approximate, arrives instantly, and is what stops things. They should share one price table so their numbers are comparable, and they should not share a code path, because attribution can afford to be asynchronous and enforcement cannot.

The counters that live inside a task are the only observers present while the money is being spent, and iteration count is the first of them to cross a threshold you could defend in a review: it increments before the call it precedes, survives an error response, and needs no price table to be correct. Cumulative tokens covers the one direction it is blind in, wall clock bounds how long anyone waits, and the euro estimate is what you say out loud to people who do not care about tokens.

None of that bounds a second run. The counters end the task that trips them and reset for the next one, which is the correct behaviour and leaves an obvious hole: nothing in this chapter noticed that the same account started the same task again eleven seconds later, and did it nine hundred times before morning. Every counter fired, every task was correctly halted, and the night still cost €4,000. Bounding a single task is one scope, and it is the innermost of three.

Denial-of-wallet — read the current version at https://daviddominguez.dev/learn/en/denial-of-wallet/in-flight-budget-counters

Back to top