Skip to content

Budgets & cost

apomesh meters every session’s spend and lets you cap it. A budget bounds a single session’s agent tree; a quota bounds a whole tenant across every session. The substrate accounts cost as work runs — folding each priced action onto the event log — and enforces the caps from that same accounting. This page covers the three places you set a budget, how the cap carves down the agent tree, what happens when a session exhausts one, how a quota differs, and where spend surfaces in the console.

A Budget has three independent dimensions — tokens, time, and cost — and you declare any subset, including none. A dimension you leave unset pins no cap on that dimension; an all-unset budget runs the agent unconstrained (bounded only by the rings above it — the substrate global cap and the tenant quota).

  • tokens — a whole-number token ceiling across the agent’s LLM calls.
  • time — a wall-clock ceiling, expressed in whole seconds.
  • cost — a monetary ceiling. Cost is carried as an integer count of micros (millionths of a currency unit) plus a currency tag, not a float — integer math avoids drift when thousands of per-call costs sum into one running total. 2_000_000 micros is $2.00. USD and EUR are the currencies the substrate carries today.

Dimensions compose with AND semantics: crossing any declared dimension exhausts the budget. The three are metered independently, so a token-heavy run can trip the token cap long before the cost cap, and either ends the session the same way.

A session is a tree of agents — a planner spawns children, children spawn grandchildren. The budget you set on the root bounds the whole tree, not just the root agent, because every spawn carves the child’s allowance out of the parent’s remaining pool.

The substrate tracks this per agent as three slots: allocated (set once at spawn, never changes), consumed_self (what this agent has spent), and committed_to_children (what it has handed down). An agent’s available budget is allocated − consumed_self − committed_to_children, saturating at zero. Two paths carve it:

  • At an explicit spawn, the BudgetAllocator reserves the child’s declared budget against the parent’s available pool. It validates every dimension first and mutates nothing on failure, so a child asking for more than the parent has left fails the spawn cleanly rather than overcommitting the tree.
  • At a coordinator fan-out, a wave of N sibling members each takes an equal share of the parent’s available budget — min(requested, available / N) per dimension, rounded down so the wave can never over-commit the pool. This is the per-wave carve every coordinator loop performs; see Loops for the wave structure it rides.

An unbounded root carves nothing: with no ceiling to divide, every child’s share is unbounded too, so the whole tree runs uncapped. Set a real budget on the root and it propagates down every spawn.

A budget reaches a session from one of three surfaces, in ascending order of precedence.

From the manifest (the default). A published agent’s manifest carries a default budget. Its per-role configuration can additionally carve a per-role budget across all three dimensions; a per-role budget left unset inherits the session budget. These are the shipped defaults every launch starts from.

From the console launch form. When you start a session from a published agent on the Configure & Launch surface (Catalog → Published), the launch form exposes a per-launch budget seeded from the manifest’s default. Editing it overrides the default for that one run without republishing the agent. The per-role three-dimension editor lives in the agent’s configuration, not the launch form. See The operator console.

From the SDK. Attach a Budget to the agent you dispatch — the most direct control, and the shape the console forms project onto. The shipped sample caps tokens and cost:

from apomesh.types import Budget, Currency, MonetaryAmount
budget = Budget(
tokens=200_000,
cost=MonetaryAmount(micros=2_000_000, currency=Currency.USD), # $2.00 — example
)

Time is whole seconds (time=); cost is a MonetaryAmount in micros. Omit a field to leave that dimension uncapped. See the full driver in deploy/local/sample-project/dispatch.py.

When a declared dimension crosses its cap, the substrate raises FailureClass::Budget on the offending agent and hands it to the supervisor cascade. What the cascade does next depends on the session’s on-stall policy, set at session start.

Budget exhaustion end-to-end — post-dispatch detection, the supervisor cascade, and the terminate-vs-resume fork the on-stall policy decides
  • Terminate (the default). The session ends with the budget error (Lifecycle::Failed(Budget)). Non-interactive callers — batch jobs, CI, scripts — get this behavior; a session started without an explicit policy terminates on exhaustion.
  • Pause for a human. When the session opts into human-in-the-loop on stall, the coordinator pauses at the budget boundary and emits a HitlPaused event (reason BudgetExhausted) instead of failing. An operator then approves a grant carrying additional_budget, which raises the exhausted allowance in place and resumes the session from the paused iteration with planner state intact. A grant of zero (an approve with no added budget) or a rejection terminates the session with the same budget error a Terminate policy would have raised.

A budget stall can also surface at a spawn, not only mid-dispatch: when a carve genuinely can’t fit a child into the parent’s remaining pool, the rejection folds into a Failed(Budget) terminal. During a pause the budget is not consumed — the resolver blocks the session’s dispatches, so cost accounting halts and the grant raises the ceiling rather than refunding spend. A paused session surfaces in the console’s HITL facet (Execution mode), where you resolve it. The orchestrator owns these enforcement points; see The orchestrator.

A per-session budget is distinct from a per-tenant quota. A budget bounds one session’s agent tree and is enforced from that session’s cost accounting. A quota is a daemon-side, rolling-window ceiling evaluated across every session for a tenant, held in one QuotaTracker for the daemon’s lifetime.

Operators configure quotas as (dimension, period) → ceiling mappings on the tenant’s config. The dimensions cover both cumulative and rate-shaped ceilings:

DimensionShapeFed by
DollarsPerPeriod, TokensPerPeriodcumulative spend/tokens per windoweach CostEvent
RatePerSecond, RatePerMinutedispatch ratedispatch starts
Concurrentin-flight agents right nowdispatch start/end

Each ceiling is one [[tenants.<id>.quota]] entry:

[[tenants.acme.quota]]
dimension = "dollars_per_period"
period = "month"
dollars = "250.00" # quoted exact decimal — TOML has no decimal type,
# and a float would round on the way into a ceiling
[[tenants.acme.quota]]
dimension = "tokens_per_period"
period = "day"
count = 5_000_000
[[tenants.acme.quota]]
dimension = "concurrent"
count = 8 # `period` is taxonomy here; the window is implied

One entry per (dimension, period) — a duplicate is a boot error, because which of two would bind depends on authoring order. A ceiling must be positive: omitting the entry is how you say “no ceiling on that dimension”, so zero would be a third decision wearing a ceiling’s representation. period is required for the two cumulative dimensions, where it decides when the running total resets.

Periods reset at Hour, Day, Week, or Month boundaries (UTC). The tracker checks the ceiling before each dispatch: a crossed ceiling raises FailureClass::QuotaExceeded { dimension, period } and blocks the dispatch, independent of any single session’s budget. Quotas are soft ceilings by design — the check-then-record path is non-atomic, so a burst can slip a couple of dispatches past a tight Concurrent ceiling before the counter catches up; the next dispatch observes the inflated count and is refused. They exist to bound runaway behavior and give billing-period control, not to enforce strict concurrency invariants.

A cumulative ceiling needs a durable ledger

Section titled “A cumulative ceiling needs a durable ledger”

A quota is a ceiling plus a running total, and only the ceiling is durable for free. The running total for the two cumulative dimensions (DollarsPerPeriod, TokensPerPeriod) lives in a per-tenant usage ledger keyed (dimension, period, window start) — Postgres-backed on a Postgres [statestore] backend, in-process otherwise. On the in-process arm a Month ceiling bounds only the time since the last restart, because each restart opens a fresh window; the daemon logs which arm it resolved at boot, and warns on the in-process one whenever a tenant quota is configured.

The lifecycle-derived dimensions deliberately do not persist, and that is not the same gap: after a restart nothing is running, so a Concurrent count of zero is the correct reading rather than a lost one, and the one- and sixty-second rate windows have genuinely elapsed.

Replacing a tenant’s ceilings — editing the TOML and restarting, or PATCH /api/v1/tenants/{id}/quotas — re-reads the running total rather than zeroing it. Raising a monthly ceiling mid-month changes the ceiling; it does not un-spend the month.

Between the per-agent budget and the tenant quota sits one more ring: a deployment-wide per-agent ceiling the operator sets, which bounds every agent whatever budget it declared — including the common case of declaring none.

[substrate.global_cap]
tokens = 10_000_000
# time_seconds = 3600
# [substrate.global_cap.cost]
# micros = 5_000_000 # $5.00
# currency = "Usd"

Each dimension is independently optional and an omitted one imposes no ceiling there; the shipped default caps nothing, because the substrate does not impose ceilings you did not ask for. A dimension set to zero is a boot error for the same reason a zero quota ceiling is — leaving it out is how you say “no cap”, so zero would have to mean something else, and the only thing it could mean is refusing every agent before its first call.

The cap is per agent, not per tree: a session that spawns N agents may spend N times the cap, because each of those agents is separately bounded. A ceiling over a whole tenant is the quota’s job, which is aggregate by construction. When it fires it raises the same FailureClass::Budget as per-agent exhaustion, and stopping a workload already in flight is reported as a cancellation — never a failure class, which would invite the supervisor cascade to restart exactly what the ceiling stopped.

Every priced action emits a CostEvent onto the session’s event log — the single source of truth the rollups fold over. A CostEvent carries the agent it attributes to, the prompt / completion / cached token counts, a media_units counter (per-asset media metering, e.g. video-seconds), and a cost breakdown split into prompt, completion, cached, and total USD. Two kinds emit today: LLM calls (CostSource::LLMCall), and paid tool executions (CostSource::ToolExecution) — a tool whose own dispatch makes an internal LLM call, such as the wrapped web-search tool, reports its cost across the sandbox boundary. A per-dispatch overhead source is reserved for a later increment.

A spend carries only the dimensions it actually has. provider, profile and model are optional, and a tool execution rides its own tool_id instead of borrowing the provider’s slot. That is what keeps a tool from appearing as a provider in the ledger, and keeps “this cost had no model” distinct from “this cost’s model is the empty string” — they were the same value once, and the blank key held real dollars.

The orchestrator folds every CostEvent into per-session, per-agent, and per-tenant totals and serves them over three RPCs — QuerySessionCost, CostForAgent (self plus subtree), and CostForTenant. Each rollup breaks down across four dimensions, and a fold takes a dollar only where the event carries that dimension:

DimensionKeyed onWhy it is its own dimension
by_providerprovider kindthe vendor the spend went to
by_profileresolved profile idtwo instances of one kind (openai-prod, openai-eu) are distinguishable
by_modelmodel idcarries no blank key
by_tooltool ida paid tool is not a provider

Tenant isolation is intrinsic: the only tenant you can query is your own.

The per-tenant rollup is cumulative for the daemon’s lifetime, so it deliberately carries no per-session or per-agent maps — those would grow one entry per session and per agent ever, reclaimed by nothing. A per-session figure is the completed-session index’s to give; a per-agent figure is CostForAgent’s.

In the console, open the Cost facet in Execution mode (see The operator console). With the session’s root selected it shows the session-wide rollup; select a child agent and it splits into that agent’s self-cost and its subtree, with by-model bars and prompt / completion / cached token rows. Because every CostEvent rides the event bus, a tail consumer can forward spend to a monitoring stack — the Prometheus tail-consumer sample exposes per-tenant, per-provider cost counters for Prometheus to scrape.

Cost figures are only as accurate as the per-model rate table behind them. Each model carries a ModelRate: per-million-token USD rates for input, cached, and output, plus two media axes — per_second_usd (a video model billing per clip-second) and per_unit_usd (a per-artifact model billing per generated image or TTS request). A token call costs (rate × tokens) / 1_000_000 per component; a media generation bills its per-second or per-unit rate against the metered units. Every rate is validated non-negative at config decode, so a mis-typed negative rate is rejected rather than minting a negative (credit) cost.

Rates live in the model catalog, provenance-layered so a shipped default and an operator override coexist without the override being clobbered on re-import. You edit them in the console’s Infrastructure → Models surface. If a model has no rate entry, the substrate emits a zero cost breakdown and a warning: the token counts stay authoritative, but the dollar figure under-counts until you add a rate for that model.

Work submitted through a provider’s batch API bills at 50% of the synchronous rate. That is not an apomesh convention — Anthropic Message Batches, the OpenAI Batch API, Gemini batch mode, and Bedrock batch inference all publish the same discount, so one constant covers every provider. The discount applies where each provider projects its batch entries into priced results, so a batch job’s rollup and its budget consumption both reflect the rate you were actually charged.

  • The operator console — the Cost and HITL facets, and the Configure & Launch budget field.
  • Monitoring — forwarding CostEvent to Prometheus and Grafana.
  • The orchestrator — the budget, quota, and cost-aggregation enforcement points.
  • Loops — the coordinator waves the per-wave budget carve rides.
  • The event log — the bus the cost rollup folds over.