Cost Attribution¶
SynthOrg records every LLM call with a CostRecord (src/synthorg/budget/cost_record.py) that carries enough dimensions to slice spend four ways: provider, model, agent, and project. This guide walks through reading the rollup, choosing the right query, and wiring alerts at each granularity.
Dimensions¶
| Dimension | Source | Cardinality |
|---|---|---|
| Provider | Provider driver name | ~10 |
| Model | Provider model identifier | ~50 |
| Agent | agent_id from the executing context |
Registry-bound (~100s) |
| Project | project_id from the task context |
Hundreds to thousands |
All dimensions are bounded label values when surfaced as Prometheus metrics; see docs/guides/monitoring.md for the registry-bound enforcement rule.
Querying the rollup¶
The cost API lives under /api/v1/budget:
GET /api/v1/budget/configreturns the configured budget, thresholds, and currency.GET /api/v1/budget/recordsreturns the raw record stream. It is paginated, and filterable byagent_id,task_id,project_id,provider, and a time window.GET /api/v1/budget/agents/{agent_id}returns one agent's total spend.GET /api/v1/budget/prompt-class-breakdownslices spend, latency, cache-hit, retry and success by prompt purpose.GET /api/v1/analytics/overviewcarries the period total, the remaining budget and the percentage used.
# The configured budget and its alert thresholds.
curl -s -b cookies.txt http://localhost:3001/api/v1/budget/config | jq
# Raw records for one agent (server default limit is 50).
curl -s -b cookies.txt "http://localhost:3001/api/v1/budget/records?agent_id=agent-1&limit=100" | jq
# Raw records for one project, since a date.
curl -s -b cookies.txt "http://localhost:3001/api/v1/budget/records?project_id=proj-acme&start=2026-05-01T00:00:00Z" | jq
# One agent's total.
curl -s -b cookies.txt http://localhost:3001/api/v1/budget/agents/agent-1 | jq
Every response that carries a money total carries what that total measures. Against a connection billing by flat subscription the total is a correct zero that measures nothing, so a percentage read on its own says the budget is untouched on exactly the estate nobody can see. See budget.md for the verdicts.
Worked example: route a Slack alert at 80% project budget¶
Set the project budget in the company template:
budget:
projects:
proj-acme:
monthly: 250.00
currency: GBP
alerts:
warn_at: 50
critical_at: 80
hard_stop_at: 95
Configure the notification dispatcher to route warning-and-above alerts to Slack:
notifications:
sinks:
- type: slack
enabled: true
params:
connection: ops-slack # a bound SLACK connection holding the bot token
channel: C0123456789
min_severity: warning
The enforcer fires BUDGET_PROJECT_BUDGET_EXCEEDED and the dispatcher fans the notification out to every enabled sink at or above min_severity. On hard-stop (95% in the example), the project's tasks are auto-cancelled and a notifications.budget_exhausted.send event lands on the notification feed.
Aggregation under concurrency¶
CostTracker.record(...) is async and lock-guarded; concurrent writes from many agents collapse to a single durable append. The per-currency invariant (assert_currencies_match) protects against accidental cross-currency rollups; mixed-currency calls raise at record time rather than silently producing a wrong total.
Limitations¶
/api/v1/analytics/overviewreports the billing period total. Narrower windows come from/api/v1/budget/recordswithstart/endbounds.- Per-tool cost is NOT a first-class dimension. Tools are observed via
synthorg_tool_invocations_total; cost attribution stops at the model + provider level. - Project assignment relies on
task.project_idbeing set; unassigned tasks aggregate under the implicitunassignedproject bucket. - Money attributes nothing on a flat-rate connection. The dimensions above still slice
a zero four ways; bound those runs with
budget.run_hard_token_ceilinginstead.
Observability¶
synthorg_cost_total(gauge): total accumulated spend.synthorg_budget_used_percent(gauge): monthly utilisation, published only while the state beside it ismeasured.synthorg_budget_spend_measurability{state}(gauge): what the percentage beside it measures about the period's spend, as a state set overmeasured/mixed/unmeasurablewith exactly one series at 1. Read them together; a panel on the percentage alone shows an untouched budget on a flat-rate estate. Onlymeasuredcarries a utilisation. Undermixedthe metered rows are real but the flat-rate ones contributed nothing, so the ratio understates by an unknown amount, and underunmeasurableit carries no information at all; the gauge holds zero on both, which is why the state is what a panel or alert must qualify on. This matchesSpendingSummary.budget_used_percent, which isNoneon both.synthorg_budget_daily_used_percent(gauge): daily utilisation (pro-rated).synthorg_budget_daily_spend_measurability{state}(gauge): the same state set for the daily window, which can disagree with the period one.
Events emitted on every record:
budget.cost.recorded: at successful persistence.budget.cost.record_rejected: at currency mismatch.budget.enforcement.check: pre-flight budget check (allow / downgrade / deny).
See docs/design/budget.md for the full design.