Team budgets¶
Cap what each user, each team, or everyone may spend, per UTC day or month, across every
worker and host. BudgetHook keeps the spend in a shared BudgetStore; each model call
holds an estimate first, so racing calls cannot overspend by more than the gap between
their estimates and their real costs.
sequenceDiagram
participant W as Worker (any process)
participant H as BudgetHook
participant S as BudgetStore (SQLite, Redis, SQL)
participant M as Model
W->>H: before_llm
H->>S: reserve(estimate) — atomic check and hold
alt fits
S-->>H: accepted (budget.reserved)
H->>M: call
M-->>H: reply with usage
H->>S: settle(real cost)
else over the limit
S-->>H: refused
H-->>W: BudgetExceeded (budget.exceeded)
end
Note over H,S: a failed or dropped call releases its hold
1. Limits per user and per team¶
TEAMS = {"ana": "support", "bo": "support", "cy": "returns"} # from your user directory
def team_of(ctx: ToolContext) -> str:
"""The team a call is charged to; an unknown user is refused (KeyError)."""
return TEAMS[ctx.user_id]
def budgets(store: BudgetStore) -> list[BudgetHook]:
"""The same limits on every worker; the store holds the shared spend."""
return [
BudgetHook(0.50, scope="user", per="day", store=store), # each agent: $0.50 a day
BudgetHook(0.80, scope="team", per="day", scope_key=team_of, store=store),
]
scope |
Charged to | Needs |
|---|---|---|
"user" / "principal" |
the authenticated principal (HTTP server), else ctx.user_id; callers with neither share one anonymous counter |
never set user_id from client input; a different user_id beside a principal is ignored (one WARNING) |
"team" |
scope_key(ctx) |
a non-empty string; a raising or empty key refuses the call |
"global" |
everything | — |
"run", "thread" |
the run or thread | as in Budgets |
per="day" or per="month" resets at the UTC boundary; per=None never resets.
budget="chat" names the counter, so two budgets of one scope can share a store.
2. Share the spend between workers¶
store = SQLiteBudgetStore(Path(folder) / "budgets.db") # one file, every process
workers = [Executor(ledger=Ledger(prices=PRICES), hooks=budgets(store)) for _ in "ab"]
house = CallableModel(house_llm, provider="host", model="house-llm")
ask = LLMRequest(messages=(Message(role="user", content="Where is my parcel?"),))
for turn, user in enumerate(["ana", "ana", "bo", "bo", "ana", "cy"]):
worker = workers[turn % 2] # requests land on either worker
ctx = worker.context(user_id=user) # the authenticated user, set by the host
try:
await worker.model(house).bind(ctx).generate(ask)
print(user, "answered")
except BudgetExceeded as exc: # refused before the model is called
print(user, "refused by the", exc.scope, "budget")
| Store | Shared by | Atomic by |
|---|---|---|
MemoryBudgetStore() (default) |
one process; past max_keys live counters a new key is refused |
a lock |
SQLiteBudgetStore(path) |
every process on one host; the file is created 0600 (POSIX SQLite gives -wal/-shm the same mode) |
BEGIN IMMEDIATE |
RedisBudgetStore(lambda: Redis.from_url(url)) |
every host (Redis 5+; usage also runs on a replica) |
one cached Lua script per step |
SQLAlchemyBudgetStore("postgresql+asyncpg://...") |
every host | a row lock (UPDATE), always READ COMMITTED on Postgres |
Install agent-adapter[redis] or agent-adapter[sqlalchemy] for the last two;
agent-adapter doctor reports whether they are ready.
3. Reserve, settle, release¶
| Step | When | Counts |
|---|---|---|
| reserve | before each model call | the estimate, held for at most hold_s (default 900 s: keep it above your longest call) |
| settle | when the call ends | what the ledger bills for it: cache hits and hook replies are $0; a nested instrumented call is charged once |
| release | the call ends unbilled: refused by a later hook, cancelled, closed mid-stream | nothing |
The estimate is the prompt (4 characters a token) plus max_output_tokens (1024 when
unset), at the model's price. Pass estimate_usd=0.05 or estimate_usd=lambda inv: ...
to set your own. A call is refused when spent plus held is at the limit, or the estimate
would cross it. Tool calls are checked but hold nothing: the ledger bills them at $0.
Prices come from prices= or the context's Ledger. A model with no price counts as $0
and logs one WARNING, as in the ledger.
4. Read what is left¶
user_cap, team_cap = budgets(store)
mine = await user_cap.status(workers[0].context(user_id="ana"))
team = await team_cap.status(workers[0].context(user_id="ana"))
print(mine.scope, mine.scope_id, round(mine.spent_usd, 2), round(mine.remaining_usd, 2))
print(team.scope, team.scope_id, round(team.spent_usd, 2), team.window.split(":")[0])
status(ctx) returns a BudgetStatus: limit_usd, spent_usd, reserved_usd,
remaining_usd, window, resets_at (epoch seconds) and view() for JSON.
Over HTTP, GET /budget lists the caller's own user and team budgets; GET /budget/team
filters by scope (HTTP server).
| Caller | Sees |
|---|---|
| any authenticated principal | its own user budget and its own team's |
a holder of approvals:decide |
also global, and any user or team with ?key= |
anyone else asking for ?key= or /budget/global |
403 |
Output¶
ana answered
ana answered
bo answered
bo answered
ana refused by the team budget
cy answered
user ana 0.4 0.1
team support 0.8 day
Notes¶
| Note | Detail |
|---|---|
| Hook order | each budget hook reserves on its own; a refused call gives back the holds of the others when it ends (on_llm_end) |
| Crashes | a worker that dies mid-call leaves its hold; it frees itself after hold_s |
| Events | budget.reserved and budget.exceeded carry the scope and window, never the user or team id; the run viewer marks a run a budget refused |
| Backend down | the store's error fails the call (closed); a failed settle is logged and the hold expires |
| Limits live in code | every worker must build the same hooks; the store holds only spend |
Files¶
| File | Does |
|---|---|
examples/howto_team_budgets.py |
user and team caps per day, two workers on one SQLite file |
tests/docs/test_team_budgets_example.py |
runs it offline and checks the output |