If you read one line: a spend cap that lives in a Python variable is not a cap for an autonomous agent — the crash loop you're guarding against resets it to zero on every restart, so put the ledger in durable storage, increment it atomically before each call, and it becomes a wall the agent cannot spend past.
Every good guide to capping agent cost — including our own dollar-budget accumulator — shows the same shape: wrap the model call, read the usage object, convert tokens to dollars, add to a running total, stop at the ceiling. That shape is correct for a single request-scoped run. It is the wrong shape for a long-running or autonomous agent, and the reason is subtle enough that it ships to production constantly.
1. Why total += cost is not a cap#
The state that a runaway agent destroys is process state. A failing agent doesn't politely overspend and stop — it wedges, crashes, and gets restarted by your supervisor. Each restart is a brand-new process with a brand-new total = 0. Your "cap" resets on the exact event it exists to survive.
# The pattern every tutorial shows. Fine for one run; useless for an agent
# that restarts. On crash-restart, total is back to 0.00 and the wall is gone.
total = 0.0
def guarded_call(**kw):
global total
if total >= DAILY_CAP: # DAILY_CAP in dollars
raise RuntimeError("over budget")
msg = client.messages.create(**kw)
total += cost(msg.usage) # resets to 0 the moment the process dies
return msg
There's a second, quieter failure: run two workers and both read total before either writes it back. Both see "under budget." Both spend. The cap is now advisory.
The fix for both is the same move the industry keeps making everywhere else — take the state out of the process.
2. Put the ledger where the crash can't reach it#
Keep the counter in Redis, keyed by the UTC calendar day, denominated in integer micro-dollars ($1 = 1,000,000 micro-dollars — floats drift under thousands of daily increments; integers don't). The key carries a TTL so yesterday's ledger evaporates on its own.
import datetime, anthropic, redis
r = redis.from_url("redis://localhost:6379/0")
client = anthropic.Anthropic()
DAILY_CAP = 25_000_000 # $25.00/day, as micro-dollars
KEY_TTL = 60 * 60 * 48 # 48h; the day key self-expires
# $ per 1M tokens: (input, output, cached_input). Illustrative — read live
# values from https://www.anthropic.com/pricing and never hardcode as permanent.
PRICES = {
"claude-opus-4-8": (5.0, 25.0, 0.50),
"claude-sonnet-5": (3.0, 15.0, 0.30),
"claude-haiku-4-5": (1.0, 5.0, 0.10),
}
def day_key() -> str:
today = datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%d")
return f"spend:{today}"
def cost_micros(model: str, usage) -> int:
inp, out, cached = PRICES[model]
cached_in = getattr(usage, "cache_read_input_tokens", 0) or 0
# $/1M tokens == micro-dollars per token, so the rate IS the per-token cost.
# Anthropic's input_tokens excludes cached input; price it separately.
return round(usage.input_tokens * inp + cached_in * cached + usage.output_tokens * out)
3. Check and increment in one atomic step#
The race in §1 is a read-then-write gap. Close it by doing the read, the ceiling check, and the increment as a single server-side operation no other command can interleave with. In Redis that's a Lua script:
# Atomically: reserve `cost` against the day's cap, or refuse.
# Returns the new total, or -1 if the reservation would breach the cap.
RESERVE = r.register_script("""
local spent = tonumber(redis.call('GET', KEYS[1]) or '0')
local cost = tonumber(ARGV[1])
if spent + cost > tonumber(ARGV[2]) then
return -1 -- would breach: reserve nothing
end
redis.call('INCRBY', KEYS[1], cost)
redis.call('EXPIRE', KEYS[1], tonumber(ARGV[3]))
return spent + cost
""")
A spend cap is only as trustworthy as its accounting under concurrency. "I set a budget" and "two workers can't both slip under it" are different claims, and only the atomic one holds.
4. Reserve the worst case, then reconcile#
You can price the input side of a call exactly and for free before you spend — the token-counting endpoint returns the same input_tokens you'll be billed. The output side you can't know until it's generated, so bound it with max_tokens. Reserve that worst case atomically, make the call, then refund the unused headroom.
class BudgetExceeded(Exception):
pass
def budgeted_call(model: str, max_tokens: int, **kwargs):
key = day_key()
# 1. Price the worst case: exact input (free) + max possible output.
counted = client.messages.count_tokens(model=model, **kwargs)
inp, out, _ = PRICES[model]
worst_case = round(counted.input_tokens * inp + max_tokens * out)
# 2. Reserve it atomically. One round trip, no read-then-write race.
after = RESERVE(keys=[key], args=[worst_case, DAILY_CAP, KEY_TTL])
if after == -1:
raise BudgetExceeded(f"daily cap ${DAILY_CAP / 1e6:.2f} would be breached")
# 3. Call, then reconcile the reservation down to the real cost.
try:
msg = client.messages.create(model=model, max_tokens=max_tokens, **kwargs)
except Exception:
r.incrby(key, -worst_case) # call failed: release the reservation
raise
r.incrby(key, cost_micros(model, msg.usage) - worst_case) # refund the slack
return msg
Note the failure direction. If the process dies between the reserve and the reconcile, the worst-case reservation stays on the books — you over-count, never under-count. For a spend cap, failing toward "too conservative" is failing safe.
5. Two ceilings, one loop#
The durable day counter is your outer wall. Inside a single run, keep a cheap in-process ceiling too — a run is one process, so in-memory is fine here — and use it to drive graceful degradation.
class RunBudget:
def __init__(self, cap_micros: int, warn_at: float = 0.8):
self.cap, self.warn_at, self.spent, self.halted = cap_micros, warn_at, 0, False
def charge(self, micros: int) -> None:
self.spent += micros
if self.spent >= self.cap:
self.halted = True # hard stop: trip the breaker
def model_for_next_step(self) -> str:
if self.spent >= self.warn_at * self.cap: # soft: degrade, don't die
return "claude-haiku-4-5" # cheaper model, keep going
return "claude-opus-4-8"
The agent loop wires them together. The soft threshold downshifts the model; the hard wall — either ceiling — trips the circuit breaker and stops the loop:
run = RunBudget(cap_micros=2_000_000) # $2.00 per run
while work_remaining and not run.halted:
model = run.model_for_next_step()
try:
msg = budgeted_call(model, max_tokens=1024, messages=messages)
except BudgetExceeded:
log.warning("daily wall hit; halting run with partial result")
break # hard stop, graceful exit
run.charge(cost_micros(model, msg.usage))
messages = advance(messages, msg)
This is a genuinely different control than a retry budget, which bounds attempts; this bounds dollars, and the two belong on the same agent.
6. Prove the cap actually holds#
An untested cap is a hope. Two tests earn the trust.
Crash test. Spend, throw the process away, and confirm a fresh one sees the prior spend — the thing total += cost fails:
r.delete(day_key())
RESERVE(keys=[day_key()], args=[20_000_000, DAILY_CAP, KEY_TTL]) # "process A" spends $20
# ...process A dies here; nothing in memory survives...
assert int(r.get(day_key())) == 20_000_000 # "process B" still sees $20 spent
Concurrency test. Fire 100 workers that each fit alone but not together, and assert the ledger is never oversold:
import concurrent.futures as cf
r.delete(day_key())
def try_reserve(_): # each wants $0.40
return RESERVE(keys=[day_key()], args=[400_000, DAILY_CAP, KEY_TTL])
with cf.ThreadPoolExecutor(max_workers=100) as pool:
results = list(pool.map(try_reserve, range(100)))
granted = sum(1 for x in results if x != -1)
assert int(r.get(day_key())) <= DAILY_CAP # never oversold: 62 win, $24.80
The pattern is provider-agnostic: swap PRICES and the usage field names (prompt_tokens / completion_tokens on OpenAI) and everything else holds. What makes it a hard cap isn't the dollar math — the tutorials get that right. It's that the ledger outlives the process and the check can't race. Build it there once, and the agent can crash-loop all night against a wall it cannot move.



