Cap spend per user¶
Each user may spend $0.25 a day on model calls. When they reach it, stop their calls, not everyone's.
flowchart LR
REQ["request from user U"] --> V["executor_for(U, today)<br/>base.replace(ledger=Ledger U·day)"]
V --> H{"BudgetHook scope=global<br/>U's ledger total ≥ $0.25?"}
H -- no --> M["model call"] --> L[("U's ledger for today")]
H -- yes --> X["BudgetExceeded"]
L -.-> H
One executor view per user and day¶
base = Executor(Registry(), hooks=[BudgetHook(0.25, scope="global")]) # $0.25 a day
views: dict[tuple[str, date], Executor] = {}
def executor_for(user_id: str, today: date) -> Executor:
"""This user's executor for today: the same hooks, the user's own ledger."""
key = (user_id, today)
if key not in views:
# "global" reads the context's ledger, so it sums only this user's day
views[key] = base.replace(ledger=Ledger(prices=PRICES))
return views[key]
BudgetHook reads the ledger of the call's context. scope="global" sums everything
that ledger holds, and here that ledger holds one user's day. replace() keeps the hooks
and shares the run guards with base.
Spend¶
ask = LLMRequest(messages=(Message(role="user", content="hi"),))
model = CallableModel(house, provider="host", model="house")
today = date(2026, 10, 30)
for user in ("asha", "asha", "asha", "asha", "ravi"):
executor = executor_for(user, today)
try:
async with executor.run_scope() as ctx: # one run per request
await executor.model(model).bind(ctx).generate(ask) # $0.10
print(user, "ok", round(executor.ledger.total_usd(), 2) if executor.ledger else 0)
except BudgetExceeded as exc:
print(user, "stopped", exc.scope, round(exc.spent_usd, 2))
The hook checks before each call, so the third call (started at $0.20) runs and ends at $0.30. Set the cap one call below the hard limit if it must never be crossed.
Pitfalls¶
| Pitfall | Do instead |
|---|---|
| Several processes | the in-memory Ledger counts one process. Add a CallbackSink that adds each entry.cost_usd to a per-user row in your database, and a small Hook whose before_llm / before_tool reads that row |
views grows forever |
drop yesterday's keys, or use an LRU dict |
| Unknown prices | an unpriced model costs None, counted as $0: load your prices with Prices.load(...) or Prices.default().merged(...) |
| A per-conversation cap | that is BudgetHook(..., scope="thread") with thread_id, no extra ledger (Budgets) |
Files¶
| File | Does |
|---|---|
examples/cookbook_user_budget.py |
per-user ledgers, the hook, four calls by one user and one by another |
tests/docs/test_cookbook.py |
runs it offline and checks the output |