Budget & Cost Management¶
SynthOrg treats money as a first-class runtime constraint. Every LLM call carries a currency-stamped CostRecord, budgets cascade from the company down to individual teams, and three layers of enforcement (pre-flight, in-flight, task-boundary) prevent runaway spending without breaking in-progress work. The agent execution pipeline that drives each layer is documented in Agent Execution > AgentEngine Orchestrator.
Budget Hierarchy¶
The framework enforces a hierarchical budget structure. Allocations cascade from the company level through departments to individual teams.
graph TD
Company["Company Budget (100 per month)"]
Company --> Eng["Engineering (50%) = 50"]
Company --> QA["Quality/QA (10%) = 10"]
Company --> Product["Product (15%) = 15"]
Company --> Ops["Operations (10%) = 10"]
Company --> Reserve["Reserve (15%) = 15"]
Eng --> Backend["Backend Team (40%) = 20"]
Eng --> Frontend["Frontend Team (30%) = 15"]
Eng --> DevOps["DevOps Team (30%) = 15"]
Note
Percentages are illustrative defaults. All allocations are configurable per company.
Numeric amounts in the diagram are unitless; budget.currency is an ISO 4217 code
resolved per the regional-defaults chain (user/company setting -> browser/system ->
neutral fallback). SynthOrg stamps budget.currency onto every row at
record-creation time; historical rows retain the code that was active when they were
written, so changing the setting only affects newly created rows. Numeric cost values
are never converted; updating the setting relabels the display symbol for future
records, not the existing ones.
Cost Tracking¶
Every API call is tracked with full context:
{
"agent_id": "sarah_chen",
"task_id": "123e4567-e89b-12d3-a456-426614174000",
"prompt_class_id": "system:memory:rerank",
"provider": "example-provider",
"model": "example-capable-001",
"input_tokens": 4500,
"output_tokens": 1200,
"cost": 0.0315,
"currency": "<operator-configured>",
"billing_model": "per_token",
"timestamp": "2026-02-27T10:30:00Z"
}
Every CostRecord, TaskMetricRecord, and AgentRuntimeState carries its own currency
(ISO 4217 code validated against the allowlist in synthorg.budget.currency). The
budget.currency setting determines the currency stamped on new rows; historical rows
retain the code that was active when they were created, so changing budget.currency
is safe and does not invalidate history.
Every aggregation site (CostTracker, ReportGenerator, CostOptimizer,
per-agent / per-department / per-project rollups, the HR WindowMetrics multi-window
strategy, and the parallel-execution coordinator) enforces a same-currency invariant by
calling assert_currencies_match (from synthorg.budget.currency) before any
reduction. Mixing currencies raises MixedCurrencyAggregationError (HTTP 409,
error code 4007, symbolic code MIXED_CURRENCY_AGGREGATION) at the aggregator
rather than silently producing a meaningless total. Pre-push gate
scripts/check_currency_aggregation_invariant.py AST-walks src/synthorg/ for
unguarded sum / math.fsum / statistics.mean / statistics.fmean calls
(including bare-name imports such as from statistics import mean) over
.cost / .amount / .total_cost / .usd / .eur attributes and fails the
push when an aggregation is not preceded by a guard call in the same
function-or-module scope. CostTracker.record()
additionally rejects at the ingestion boundary when the incoming record's currency differs
from the configured budget.currency, so new writes cannot introduce drift
against the live setting. Historical rows written before a budget.currency change still
carry their original code, so a rollup that spans the change window will legitimately see
mixed currencies; the aggregator raises rather than silently combining them. Operators
who change budget.currency should either scope reports to a single currency window or
run a proper migration that converts both the numeric amount and the currency code
together under a documented FX policy; a raw
UPDATE cost_records SET currency = '<new-code>' is a re-label, not a conversion,
and must only be used when the operator knows the existing numeric values are already
denominated in the target code (for example, correcting an initial mis-configuration
before any production data accumulated). SynthOrg does not ship an FX engine; callers are
responsible for the conversion policy when they need one.
CostRecord stores input_tokens and output_tokens; total_tokens is a @computed_field
property on TokenUsage (the model embedded in CompletionResponse). Spending aggregation
models (AgentSpending, DepartmentSpending, PeriodSpending) extend a shared
_SpendingTotals base class that also carries the per-aggregation currency.
Beside the two token counts the record carries cache_read_input_tokens and
cache_write_input_tokens, the prompt-cache counts a provider reports on each
response, as COUNTS rather than a boolean: a hit flag says a cache was touched
and nothing about how much of the input it covered, which is the figure that
maps to the bill. Both are columns on cost_records in both backends, so a
record read back carries what was recorded rather than a None that reads as
"never cached". Call analytics derives cached_input_share from them (cached
read tokens over input tokens, absent when a slice has no input tokens), and
that share is what the dashboard's analytics sections show in place of a hit
rate.
Is the money figure measuring anything?¶
A provider that bills by flat subscription has no per-1k price to attribute, so every
call it serves records cost = 0.0. That zero is the correct number and it is not
headroom: a money ceiling compared against it can never bind, and a budget page
reading 0.00 cannot tell "nothing was spent" from "money never measured this".
So each connection declares how it charges. BillingModel (core/billing_enums.py)
is per_token, flat_rate or unknown; a preset declares it, ProviderConfig carries
it seeded from the preset at create time, and the operator can correct it afterwards
because they know their own contract better than a shipped table does. unknown reads
as unmeasurable rather than as metered, so an undeclared connection errs toward saying
less than it knows.
CostRecord.billing_model is carried on the row for the same reason currency is: a
connection that later changes contract must not rewrite the history of what was
measurable, and a connection since deleted must still be answerable. The mechanism is
not the same, though. Currency is supplied by the recording path and the tracker only
rejects a mismatch; the billing model is stamped centrally by CostTracker.record
from a snapshot of the provider set, overwriting whatever the caller supplied. One
owner, the connection's own declaration: a caller cannot make spend look measurable by
asserting it, and no recording path has to remember to ask. The snapshot is rebound
wherever the provider set is rebuilt (providers/_driver_binding.py::rebind_provider_set),
which is what keeps an operator's correction from reaching the ledger only on restart.
From the records a window actually aggregated, SpendingSummary.measurability is
derived (budget/spending_summary.py):
| Verdict | Meaning |
|---|---|
measured |
Every record in the window billed per token; the money total is the whole story. An empty window is measured: nothing was spent and nothing was hidden. |
unmeasurable |
Every record was flat-rate or unknown; the total is a correct zero that measures nothing. |
mixed |
Both kinds served the window, so the total is right for what it covers and understates the rest. |
SpendingSummary.budget_used_percent is None whenever the verdict is not measured.
0.0 was the lie: it says "we have spent nothing" when the truth is "this ceiling
cannot measure what we are spending". Everything downstream reads the verdict rather
than inferring safety from a low number: the budget page and the overview endpoint
surface it beside the total, and a deliverable receipt states it next to total_cost.
Writing a positive
budget.run_hard_ceiling while every configured connection is unmeasurable is refused
at write time, naming the token ceiling as the bound that does bind.
Recording Paths¶
CostRecord emission flows through two complementary paths:
- Provider-layer chokepoint (
synthorg.providers.cost_recording). Acost_recording_scope(...)async context manager binds per-call recording context (agent_id,task_id,project_id,purpose,call_category,currency,cost_tracker) to the currentasyncio.Taskviacontextvars. InsideBaseCompletionProvider.complete(), the chokepoint reads the active context after a successful response and emits aCostRecordto the bound tracker. Thepurpose(aPromptPurposeIdfromllm/prompt_purpose.py, orNone) is stamped ontoCostRecord.prompt_class_idso spend can be sliced by prompt purpose. Every non-engine LLM call site (memory consolidation, classification, verification graders, intake, evolution, HR judges, security evaluators, group chat, Chief of Staff, etc.) opens this scope so every paid LLM call is accounted for. Two pre-push lints guard it:scripts/check_provider_complete_chokepoint.pyblocks any new call site that bypasses the chokepoint, andscripts/check_cost_scope_purpose.pyblocks anycost_recording_scope()call that omitspurpose=. - Engine post-execution recorder (
synthorg.engine.cost_recording). The main agent execution loop builds per-turnTurnRecords (carrying additional metadata the chokepoint cannot reconstruct, e.g. cumulative retry counts and PTE token-response inflation) and emitsCostRecords after the loop completes. Engine call sites do not open the chokepoint scope, so the chokepoint stays silent on the engine path and there is no double-counting.
Both paths converge on the same CostTracker.record() API and the same
same-currency invariants apply.
Durability¶
CostTracker holds a rolling in-memory window (_COST_WINDOW_HOURS, 168
hours), and that window is what every spend summary and every ceiling reads.
The window alone is not the record of spend: a restart emptied it, so a
restarted backend enforced its ceiling against zero and every deliverable
receipt reported nothing spent.
So cost_records is written, not merely defined. attach_durable_repos(...)
binds the CostRecordRepository alongside the project aggregate and its dedup
store, and record() appends every accepted record to it:
- Idempotent. Each record carries a
claim_id, unique per(claim_id, timestamp)in the table, so a redelivered record is stored once. A record with no project used to skip dedup entirely (the aggregate increment early-returns without aproject_id), which is every system call. - Retried, then escalated. The append runs under a
GeneralRetryHandlerwith sub-second backoff (_DURABLE_APPEND_MAX_ATTEMPTS); a run of_PERSIST_FAILURE_ESCALATION_STREAKconsecutive failures stops being a WARNING nobody reads and logs at ERROR, because by then the ceiling is being enforced against an incomplete record of spend. - Rehydrated.
hydrate_from_durable()refills the window from the durable table bounded by the same 168-hour constant, oldest record first, skipping claims the process has already seen. A ceiling therefore survives a restart.
Streaming completions (BaseCompletionProvider.stream()) route through the
same chokepoint. Token counts surface only on the terminal
StreamEventType.USAGE chunk, so stream() wraps the driver's iterator in a
lazy pass-through generator (_cost_recording_stream) that yields each chunk
unchanged, captures the usage chunk, and -- once the consumer fully drains the
stream -- fires the same record_cost_if_in_scope chokepoint complete() uses.
Because draining happens in the consumer's scope, the CostRecord lands in the
caller's cost_recording_scope, not at connection-setup time. A stream that
never yields a usage chunk records nothing, matching the no-scope no-op
contract. The scope's teardown is context-safe (a plain context-var restore, so
an SSE response body that drives the generator's close in a different anyio
context than its open cannot raise).
The GET /budget/records endpoint returns paginated cost records alongside two server-computed
summaries (aggregated from all matching records, not just the current page):
daily_summary: per-day aggregation withdate,total_cost,total_input_tokens,total_output_tokens, andrecord_count, sorted chronologically.period_summary: overall stats includingavg_cost(computed),total_cost,total_input_tokens,total_output_tokens, andrecord_count.
Cost Enforcement and Optimisation¶
Budget tracking, per-task cost recording, and cost controls are enforced by
BudgetEnforcer (a service the engine composes): it blocks a task whose
estimated cost would exceed the remaining budget and evaluates every
operation against the live alert ladder before it runs.
CostOptimizer is the read-only analytical complement, exposed at
GET /budget/cfo/anomalies and GET /budget/cfo/efficiency:
- Spending anomaly detection (sigma threshold + spike factor) over a configurable trailing window.
- Per-agent cost efficiency analysis.
- Advisory model-downgrade recommendations (a cheaper model on the agent's OWN provider, since a model reached through a different connection is a different decision with its own credentials, quota and bill; see the Cost / quality Pareto view below).
- Routing optimisation suggestions.
- Operation approval evaluation, the same criteria
BudgetEnforcer's pre-flight check applies.
ReportGenerator produces multi-dimensional spending reports with
task/provider/model breakdowns and period-over-period comparison (see
Automated Reporting below).
Both services run as ordinary budget-subsystem plumbing, independent of the
roster: a company template may include a named CFO role, but the
enforcement and analysis run whether or not that role is staffed.
Cost Controls¶
The budget system enforces two layers: pre-flight checks and in-flight monitoring. Every knob here refuses spend; none of them re-points an agent at a different model.
budget:
total_monthly: 100.00
currency: "<ISO 4217 code>" # display-only, no FX conversion
reset_day: 1
alerts:
warn_at: 75 # percent
critical_at: 90
hard_stop_at: 100
per_task_limit: 5.00
per_agent_daily_limit: 10.00
Cost discipline is selection, not substitution
An agent is a fixed (role, model) unit, so budget pressure never
swaps its binding: the pair is the operator's choice about where work runs and
what it costs, and a run whose model was rewritten mid-flight recorded a
capability rung that meant nothing.
The standing discipline is the selection ladder instead. It prefers an agent at the exact rung the work demands over a stronger one, which picks the cheapest agent that can do the job on every assignment rather than only past a threshold (see Model Capability Policy). When the money genuinely runs out, the hard stops below refuse: monthly, daily, per-task, per-project, the run ceiling and the token ceiling all still halt the work, which is an outcome an operator can see and act on.
Enforced by check_no_bound_pair_rewrite.py.
Minimal Configuration
The only required field is total_monthly. All other fields have sensible defaults:
Cost as a First-Class Dial¶
Beyond the passive ledger and the soft-warning ladder, cost is a prospective, operator-facing control with three capabilities.
Pre-flight forecast gate¶
CostForecaster produces a forecast for a brief before any spend commits: a
mid-point estimated_cost plus a [lower_bound, upper_bound] uncertainty band.
The estimate is a hybrid of a per-capability static prior and a Bayesian-shrinkage blend
with historical per-role observations, so a cold start collapses to the prior and
a warm history pulls toward the observed mean.
ForecastGate sits at the work-entry seam between the entry adapters and the work
pipeline. When forecast_required is set it refuses to dispatch a brief unless a
persisted Forecast row with decision = approved covers it; a missing or pending
forecast yields a fresh pending row and raises CostForecastApprovalRequiredError
(HTTP 402) so the operator decides via the dashboard. The decision state machine is
pending -> approved | rejected | superseded; approved and rejected are terminal.
budget:
forecast_required: true
forecast_default_ceiling_multiplier: 1.5 # UI suggests ceiling = upper_bound * this
forecast_shrinkage_prior_weight: 5.0 # Bayesian prior pseudo-count
forecast_static_prior_per_turn_expert: 0.10
forecast_static_prior_per_turn_capable: 0.03
forecast_static_prior_per_turn_basic: 0.005
forecast_static_prior_per_turn_local: 0.0
Approval runs the work it gated¶
A refused brief rides with the estimate that refused it: when the gate mints a
pending forecast it stores the serialised WorkItem on the row's
gated_work_item column. Approval then re-dispatches it.
BudgetForecastService.approve calls the ApprovedForecastDispatcher port
(budget/forecast_dispatch_port.py), implemented by
engine/pipeline/forecast_redispatch.py::ForecastGateRedispatcher and wired at
boot in engine/pipeline/entry/boot.py. That adapter feeds the item back
through the gate, which now reads the row as approved, so the release
rules and the brief-drift check are applied once rather than duplicated; the run
is spawned rather than awaited, because a work pipeline outlives the HTTP
request that approved its budget.
That is what makes approval a decision that takes effect. Without it the
automation door accepted work, returned its 202, raised
CostForecastApprovalRequiredError inside a detached background task where only
the log saw it, and dropped the objective: approving the forecast afterwards
changed a row and nothing else.
A forecast generated directly through POST /budget/forecasts gated no work, so
its gated_work_item is NULL and approval is only a budget decision. A row
that does hold work and finds no dispatcher wired raises rather than returning
success, because silently dropping approved work is the failure being fixed.
On approval the work-entry intake phase stamps the forecast's forecast_id and
the operator-approved ceiling_amount onto the Task so the in-loop checker and
the engine can act on them.
Hard real-money ceiling¶
Independent of the monthly soft-warning ladder, a per-run hard ceiling halts the org
cleanly mid-run, whenever AgentEngine holds a wired BudgetEnforcer: the single
production construction site (workers/engine_assembly.py::build_agent_engine)
threads it in via budget_enforcer_of(app_state), and every check below is reachable
through that one seam. The in-loop BudgetChecker raises RunHardCeilingExceededError (a
subclass of BudgetExhaustedError) the moment accumulated cost meets or exceeds the
task's hard_ceiling (falling back to the global run_hard_ceiling setting when the
per-task value is unset). The shipped default run_hard_ceiling is 25.0, a
safety net; 0.0 is the explicit opt-out that disables the global fallback. The engine routes the
crossing to TerminationReason.PARKED via ApprovalGate.park_context so execution
state is preserved, and stamps a HaltContext (accumulated cost, ceiling, currency,
timestamp) onto the forecast row. The operator raises the ceiling via
POST /budget/forecasts/{id}/raise_ceiling (rejected with RunHardCeilingTooLowError
if the new ceiling does not clear the accumulated cost), which clears the halt context
so the run can resume.
Hard token ceiling¶
Money is not the only bound, and against a flat-rate connection it is not a bound at all. Every provider reports tokens, so the token ceiling is the runaway backstop that holds regardless of how a connection charges. It is on by default:
budget:
run_hard_token_ceiling: 50000000 # cumulative tokens per run; 0 disables the global fallback
session_token_ceiling: 2000000 # cumulative tokens per bounded helper session
Both apply without a restart, and the mechanism differs by consumer.
BudgetConfig is built once per process through the DB-blind bootstrap resolver, so
the enforcer's copy would otherwise freeze at boot for every limit it holds, not just
these two: BudgetConfigSettingsSubscriber re-resolves the whole config through the
live resolver on any budget write and hands it to the running enforcer.
Four components hold their own copy (the state slice, the enforcer, the tracker whose
copy decides which currency a record may be written in and what every gauge is computed
against, and the CostOptimizer that scores each recommendation), so
budget/adoption.py
owns reaching all four and both triggers call it. Boot is the first trigger, not a
special case: construction builds the slice, the tracker, and the optimiser from the code
defaults because no setting can be read yet, and the enforcer is built later with the
cost dial, so a deployment whose budget was configured before it started measured against
total_monthly's default of 100 for the life of the process, and restarting was what
reintroduced it rather than what fixed it. adopt_resolved_budget_config runs once
persistence and settings are up; a resolve failure leaves the boot defaults standing
rather than failing the deployment, and those defaults refuse spend sooner than any
ceiling an operator would choose.
It runs as its own boot step, not inside another subsystem's activation. Riding
inside one ties the ceiling to preconditions that are not about budget, and a boot that
declines that subsystem adopts nothing while every gauge captured beside it goes on
showing the operator's number. For the same reason adoption has to be the LAST answer
as well as the first: any later step needing a BudgetConfig reads the one in force
through resolved_budget_config rather than constructing a fresh one, because a
default minted downstream lands on the slice and on anything rebuilt from it, and the
quieter writer wins with nothing to show for it. The bounded
sessions read budget.session_token_ceiling per call through
resolve_session_token_ceiling, except the two that bake it into a frozen config at
assembly (the decomposition planner and the plan-review panel), which are rebuilt on a
write: the panel declares the key on its SubsystemSpec with rebuild_on_change, and
the planner's key is watched by the runtime-reload subscriber.
The in-loop checker reads ctx.accumulated_cost.total_tokens and raises
RunHardTokenCeilingExceededError (a sibling of RunHardCeilingExceededError under
BudgetExhaustedError) when the run's task-level hard_token_ceiling (or the global
fallback) is met. The token branch is checked first, because a flat-rate run's money
branch can never fire. Sizing: engine.max_turns is 300 with three extensions, so a
legitimate run may reach 1200 turns, which at a large context is roughly 48M cumulative
tokens; 50M lets a full-length legitimate run through and stops a genuine runaway. The
four configured helper sessions carry money ceilings of 1.0 to 2.0, so 2M is the same
generosity one tier down.
Both ceilings park the run under the same budget:hard_ceiling_exceeded action type:
it is one event ("this run hit its hard bound"), and a second action type would be a
second owner for one decision. What differs is the reason string, which names the unit,
the ceiling, the usage and the two settings that raise it. Both of those settings are
writable: the global through the settings surface, and the task's own bound through
PATCH /tasks/{id}, which carries hard_ceiling and hard_token_ceiling alike.
Naming a knob the operator cannot reach would park the run behind an instruction that
does nothing, which is the same unreachable-exit shape one layer up. The money half of
that pair is guarded where it is written, not only where it is read: hard_ceiling
goes through the same can-this-bind refusal as budget.run_hard_ceiling, both asking
money_ceiling_can_bind, because the per-task value overrides the setting and guarding
only the setting would leave the stricter number as the unguarded one.
A token halt stamps no HaltContext: that structure hangs off cost_forecasts under an
all-or-none CHECK whose columns are money and a timestamp, and a forecast estimates
money. Resume is therefore raise a ceiling and resume the parked approval, where the
rebuilt checker reads the new value.
"Is this session out of budget?" has exactly one owner, budget/session_budget.py.
build_session_budget_checker takes a SessionCeilings pair rather than two scalars,
and each session config carries the pair as one field, so a wiring path that resolves
the money bound cannot leave the token bound at its default without saying so. Five
bounded sessions ask there (decomposition, plan review, initiative evaluation,
retrospective capture, chat action), each keeping its own tuned money number and
inheriting the token backstop. The loop protocol builds its checker from the same
seam but is not one of them: it bounds a whole task run from Task.budget_limit and
Task.hard_token_ceiling, and stands in where no enforcer is wired. The gateway's
per-run token claim is a different decision with its own owner and is left alone.
The chat action reads its token bound from budget.session_token_ceiling itself rather
than accepting it from a caller, because it is one of the bounded sessions and asking
two callers to pass it is how one of them comes not to. Its money ceiling stays
caller-supplied: each console tunes its own, and it measures nothing on a flat-rate
connection anyway.
The predicate is not the whole answer this owner returns: build_session_budget_checker
wraps it in SessionBudgetChecker, a frozen pair carrying the enforced SessionCeilings
alongside the callable, so a turn-boundary consumer (the context-budget indicator's
Budget: segment, the periodic budget signal) reads the exact pair the predicate is
closed over rather than re-resolving a second, possibly-drifted answer. That holds exactly
for the token axis, which has one check (hard_token_ceiling) and it is what gets
published; it is looser for cost, where SessionBudgetChecker.ceilings.cost_ceiling
is only the tighter of the task and per-run money bounds and does not fold in an
estate-level bound (project, monthly, daily) that may be the one actually tightest for
a given run. Nothing renders cost_ceiling to the agent today, so the gap is dormant;
it would need closing before a cost-based analogue of the token budget signal is built.
Cost / quality Pareto view¶
ParetoAnalyzer answers "90% of the quality at 40% of the cost if you downgrade these
roles". It walks the current per-role model assignments and observed costs, looks up a
downgrade candidate per role, and pairs the cost_saving_pct with the quality_delta_pct
drawn from a BenchmarkScoreProvider. Each model id resolves to a capability rung through
a shared resolver (budget/model_capability.py): the built-in heuristic handles the
example-{basic,capable,expert} ids (and the example-local-* locality variants), and an
additive ModelCapabilityMap lets an operator map arbitrary deployment ids onto a
canonical rung without re-keying the candidate construction.
The quality axis is backed by MeasuredBenchmarkScoreProvider, selected by the
budget.benchmark_provider setting (measured; an unknown value fails loudly at wiring):
MeasuredBenchmarkScoreProvider(measured) reads measured per-model scores from theBenchmarkScoreRepository. A model with no measured row returnsNone, so the frontier skips it and the quality axis is shown as explicitly absent, never a fabricated number.
Measured scores are genuinely measured, never fitted: make record-benchmark-scores
(driving scripts/record_benchmark_scores.py) replays a recorded per-model cassette
through the eval spine and derives each score from the resulting Scorecard (mean
normalised brief score plus a 95% confidence band), writing the committed seed artifact
src/synthorg/budget/benchmark_seed.json. The repository is boot-seeded from that artifact
when empty, so a fresh operator database carries the measured scores without a recording
run. Every ParetoPoint and the frontier carry a source field (the per-point provenance,
joined with | when a point's current and candidate scores differ in provenance, and
comma-joined across the frontier). A model with no measured row returns no score, so it never
becomes a ParetoPoint; the quality axis renders it as explicitly absent rather than a
fabricated value. The dashboard derives a provenance badge from the source: a measured
benchmark: token renders "measured", and a role without a measured score renders "absent",
so fabricated data can never be mistaken for measured data. The frontier is advisory: downgrade callouts
link to the agent settings surface rather than mutating models inline.
Benchmark scores feed only this Pareto/quality view. Capability selection does
not consult them: it compares an agent's rung against the rung the work demands
(see Providers: capability routing).
The budget/model_capability.py heuristic that this analyser shares is also the base
signal the capability classifier builds on, so a model's Pareto rung and its
selection rung derive from the same capability metadata.
Each frontier point's downgrade candidate is a measured model one rung down, picked from the same benchmark rows the quality axis reads, so the callout always names a model the operator can actually bind. It is advisory in the strict sense: it links to the agent settings surface, and only an operator writing the new pair changes anything.
Quota Degradation¶
When a provider's quota is exhausted, the framework applies the configured degradation
strategy before failing. Each provider has a DegradationConfig specifying the strategy:
| Strategy | Behaviour |
|---|---|
alert (default) |
Raise QuotaExhaustedError immediately |
queue |
Wait for the soonest quota window to reset (capped at queue_max_wait_seconds), then retry |
Neither moves the caller onto a different connection. A provider is a registered
connection with its own credentials, endpoint and quota, so re-pointing an agent
at another one mid-dispatch would run the operator's choice somewhere nobody
chose and bill a quota nobody named. The fallback strategy and its
fallback_providers list are retired, and the two directions are deliberately
different:
- Writing one is refused by name, so an operator asking for a provider swap is told the system no longer does that rather than having it silently ignored.
- Reading one already persisted strips it, records it on the read
(
ProviderConfigsRead.coerced), and logs it once at boot. The setting is inert either way, and the only thing refusing it on read can cost is the connection that carries it: an operator who set it before the retirement would lose a working provider over a value whose correct state is now "absent". The next edit of that provider drops it from storage, since a write re-serialises from the validated model.
An agent whose provider stays out is answered at the organisation level rather
than inside the dispatch: the roster marks it unavailable
(ServiceabilityFilteredRoster) and its work is reassigned to an agent that can
serve.
providers:
example-provider:
degradation:
strategy: "queue"
queue_max_wait_seconds: 300
secondary-provider:
degradation:
strategy: "alert"
QuotaTracker also exposes a synchronous peek_quota_available() method that returns
a dict[str, bool] snapshot of per-provider quota availability. This is used by the
QuotaAwareSelector at routing time to prefer providers with remaining quota. The
method reads cached counters without acquiring the async lock (safe on the single-threaded
asyncio event loop) and tolerates TOCTOU for heuristic selection decisions.
Degradation is resolved during pre-flight checks (BudgetEnforcer.check_can_execute),
which returns a PreFlightResult recording what happened. QUEUE waits for the
quota window to rotate and then re-checks, on the same provider throughout;
ALERT raises. The dispatch that follows always runs on
identity.model.provider, which is also the provider the pre-flight was asked
about, so the call and the quota it is metered against can never come apart.
Degradation Boundary
Degradation is resolved at task assignment time (pre-flight). An agent mid-execution is never switched to a different provider, and neither is one at its boundary.
LLM Call Analytics¶
Every LLM provider call is tracked with comprehensive metadata (per-call cost and proxy-overhead metrics, call categorisation and the orchestration ratio, the nine-metric coordination suite, and the coordination error taxonomy). That analytics layer has its own design page: LLM Call Analytics and Coordination Metrics. The orchestration ratio (coordination / total) and the coordination suite are the primary signals for tuning multi-agent configurations.
Risk Budget¶
The framework tracks cumulative risk alongside monetary cost. While the
RiskClassifier assigns per-action risk levels (LOW/MEDIUM/HIGH/CRITICAL),
the risk budget tracks risk accumulation: an agent executing 50 MEDIUM-risk
actions in a row should trigger escalation even though each individual action
is approved.
Risk Scoring Model¶
Each action is scored on four dimensions (0.0--1.0):
| Dimension | Meaning | 0.0 | 1.0 |
|---|---|---|---|
reversibility |
How irreversible | Fully reversible | Irreversible |
blast_radius |
Scope of impact | None | Global |
data_sensitivity |
Data touched | Public | Secret |
external_visibility |
External parties | Internal only | Fully public |
A weighted sum produces a scalar risk_units value (default weights:
0.3/0.3/0.2/0.2). The RiskScorer protocol is pluggable; the default
implementation maps built-in ActionType values to pre-defined RiskScore
instances (CRITICAL ~0.88, HIGH ~0.62, MEDIUM ~0.31, LOW ~0.05).
Risk Budget Configuration¶
budget:
risk_budget:
enabled: false # opt-in
per_task_risk_limit: 5.0
per_agent_daily_risk_limit: 20.0
total_daily_risk_limit: 100.0
alerts:
warn_at: 75 # percent of daily limit
critical_at: 90
Zero limits mean unlimited. Risk budget is disabled by default.
Risk Tracker¶
RiskTracker mirrors CostTracker: append-only RiskRecord entries with
TTL-based eviction (7 days), asyncio.Lock concurrency safety, and
per-agent/per-task/total aggregation queries.
Enforcement¶
BudgetEnforcer checks risk limits alongside monetary limits:
- Pre-flight:
check_risk_budget()checks per-task, per-agent daily, and total daily risk limits. RaisesRiskBudgetExhaustedErroron breach. - Recording:
record_risk()scores and records each action via theRiskScorerandRiskTracker. Exhausting the risk budget refuses the action at the pre-flight check above. It does not narrow the agent's autonomy level: that grant is the operator's, and the refusal here is already the control.
Shadow Mode¶
SecurityEnforcementMode (on SecurityConfig) controls enforcement:
| Mode | Behaviour |
|---|---|
active (default) |
Full enforcement; verdicts applied as-is |
shadow |
Full pipeline runs, audit recorded, but blocking verdicts convert to ALLOW |
disabled |
No evaluation, always ALLOW |
Shadow mode enables pre-deployment calibration: operators can observe what would have been blocked without disrupting agent work, then tune risk weights and limits before switching to active enforcement.
Automated Reporting¶
The framework generates periodic reports summarising spending, performance, task completion, and risk trends. Reports are generated on demand via API or on a schedule.
Report Periods¶
| Period | Coverage |
|---|---|
daily |
Previous day (00:00 UTC to 00:00 UTC) |
weekly |
Previous week (Monday 00:00 UTC to Monday 00:00 UTC) |
monthly |
Previous month (first-of-month 00:00 UTC to first-of-month 00:00 UTC) |
Report Templates¶
| Template | Data Source | Contents |
|---|---|---|
spending_summary |
CostTracker |
Per-task, per-provider, per-model cost breakdowns |
performance_metrics |
PerformanceTracker |
Per-agent quality scores, task counts, cost/risk totals |
task_completion |
CostTracker |
Completion rates, department breakdowns |
risk_trends |
RiskTracker |
Risk accumulation by agent and action type, daily trend |
comprehensive |
All sources | Combines all templates into a single report |
API Endpoints¶
| Method | Path | Description |
|---|---|---|
POST |
/api/v1/reports/generate |
Generate an on-demand report for a given period |
GET |
/api/v1/reports/periods |
List available report periods |
Prefill Token Equivalents (PTE)¶
PTE is an additional hardware-aware efficiency metric (from arXiv:2604.05404) that accounts for KV-cache eviction between tool calls and tool-response inflation. Unlike raw token counts, PTE correlates better with wall-clock latency for tool-integrated reasoning.
Formula approximation (no internal KV state required):
PTE = input_tokens * (1 + eviction_penalty * prior_tool_call_count)
+ output_tokens
+ tool_response_tokens * tool_inflation_factor
Default tuning: eviction_penalty = 0.3, tool_inflation_factor = 1.5.
PTEConfig defines these tuning parameters where
prefill_token_equivalents(..., config=...) is called.
Integration: PTE is additive, not a replacement for token budgets. Token
budgets continue to drive per-task spend caps; PTE drives efficiency analysis via
EfficiencyRatios.pte and pte_ratio.
Configuration: budget.pte_tracking_enabled: bool = False (opt-in).
See Also¶
- Providers: provider abstraction, routing, quota
- Tools: tool invocation cost tracking
- Design Overview: full index