Budgets and Cost
Attribute spend to runs, keys, principals, and agents, check it against a provider's bill, and stop or alert on it with budgets.
Every metered call SOIT makes leaves a cost entry in the runtime ledger. Cost attribution reads those entries to say where spend went; budgets read them to limit it. Budgets and daily usage aggregates shipped in v1.2.0. v1.4.0 adds priced tool calls and image prices by quality and size, and changes how budgets hold calls in flight. v1.6.0 lines the ledger up beside a provider's bill (see Checking costs against a bill) and lets a workspace refuse calls with no price (see Calls with no price).
Where cost is recorded
- Each model call records its step, tokens, latency, and a cost entry (currency, amount, unit, quantity, provider, model or tool ref, token breakdown), whether it came from an agent, a workflow, or the gateway.
- Runs record the entry they came through (
source:platformorgateway) and the API key that started them (api_key_id). A key issued to a service principal attributes runs and costs to the principal. - A call through a virtual model is attributed to the provider that answered, or, for an image call that timed out, to the provider it was waiting on.
- Tool calls pass the same credit and budget checks as model calls. From v1.4.0 a tool can also declare a price; see Priced tool calls.
- Rehearsal runs, such as publish gates and model replays, are kept out of spend.
- New in v1.4.0. A cost entry without a price says why in
pricing_snapshot.reason, such aspricing_not_configuredfor a model with no configured price, orno_price_sourcewhen nothing names a cause.pricing_snapshot.pricedalways matches whether the entry has an amount, and an amount of0with a currency is an explicit zero price. - New in v1.6.0. Cost entries of model calls keep the provider's own ids, when it gave them:
upstream_id, the response's id (chatcmpl-…,msg_…, Gemini'sresponseId), andupstream_request_id, the request id from the provider's response headers (x-request-id,request-id). The OpenAI, Anthropic, Gemini, and LiteLLM adapters read them for chat, streams, embeddings, reranking, and images. Neither id is unique, since self-hosted servers and caching proxies repeat ids, and an id an SDK made up is not kept (LiteLLM's ownchatcmpl-<uuid>, and any id for Anthropic through LiteLLM, where only the request id survives). Embeddings and images have a request id at most; charges for unanswered image calls, tool calls, and entries written before v1.6.0 have neither.
Priced tool calls
New in v1.4.0. A tool can declare what one call costs in its ToolSpec policy:
"pricing": {"currency": "USD", "call": "0.002"}Every successful call of the tool — through POST /api/v1/tools/{ref}/invoke, MCP tools/call, an agent, or a workflow — then writes a priced cost entry that counts against budgets and deducts credits like a model call. "call": "0" is an explicit free price. A call that is not charged keeps an unpriced entry whose pricing_snapshot.reason says why:
| Reason | Meaning |
|---|---|
tool_pricing_not_declared | The tool declares no price |
tool_call_failed | The call failed; a failed call is not charged |
unsupported_pricing_config | An MCP server's price SOIT cannot read (a plugin ToolSpec with one is refused at install) |
tool_pricing_not_resolved | A workflow called a tool of an MCP server |
An agent's call to a tool of an enabled MCP server follows the tool's policy from the workspace catalog, as a direct call does: its declared price, and its approval rule. Earlier versions refuse a ToolSpec with pricing and their plugin loader skips such a tool, so once a plugin declares a price, do not roll back past v1.4.0.
Image prices
New in v1.4.0. A model's pricing_json prices images per image. image is the plain rate; image_variants lists prices for a quality, a size, or both, for providers whose price depends on them:
{
"currency": "USD",
"image": "0.042",
"image_variants": [
{"quality": "low", "price": "0.011"},
{"quality": "high", "price": "0.167"},
{"quality": "high", "size": "1536x1024", "price": "0.25"}
]
}- A call is priced by the variant that fits it, that is whose every named value equals the call's. Of several, the one naming more values wins, and of equals the first listed.
- The cost entry's
pricing_snapshotnames the variant (image_variant) and records the call'ssizeandqualityunderquantities. - A call no variant fits takes
image. Without animagerate it is recorded unpriced with the reasonimage_variant_not_priced, rather than billed at another quality's price. - A call that asks for no quality, or for
auto, is priced as one that names none.
How quality reaches the provider is described under Image options on the gateway page.
Charges SOIT estimates
New in v1.4.0. Most cost entries record what the provider reported. Two kinds of call are charged an estimate instead, because the provider was asked and its full answer never came, and SOIT giving up does not cancel the work at the provider:
- A streamed model call that ends without the provider's usage (the client left, the stream failed part way, or the backend never sends usage) is charged tokens estimated from the prompt and the text generated.
- An image call that times out after the provider was asked, or that was in flight when the process making it was lost, is charged the number of images it asked for, at its route's per-image price for the size and quality it asked for.
Both are flagged usage_estimated on the step's metrics and in the cost entry's pricing_snapshot, whose usage_estimate_basis says what the estimate counts (characters or requested_images). They count toward credits and budgets like any other call.
An image job lost with its process is failed with IMAGE_JOB_INTERRUPTED by the image job reaper, which is off unless IMAGE_JOB_REAPER_ENABLED is set; the shipped compose files and .env.example set it. An image call in progress marks its run alive every IMAGE_JOB_HEARTBEAT_SECONDS (30), and a run left unmarked for IMAGE_JOB_ORPHAN_AFTER_SECONDS (600) counts as lost. A job lost before it asked the provider is not charged. During a rolling upgrade, enable the reaper only once every replica runs v1.4.0: earlier versions send no heartbeat.
Reading spend
- Observe › Runs has an Entry filter and a key filter, and the cost overview follows them. Settings › API links each key to its runs.
- The run list (
GET /api/v1/runs), its CSV export, and every cost breakdown under/api/v1/runs/costsfilter bysourceandapi_key_id, so gateway traffic and its spend can be read on their own or per key. - New in v1.6.0. Observe › Costs shows the ledger's spend for a window, ready to set beside a provider's bill; see Checking costs against a bill.
- New in v1.6.0. Costs in different currencies are no longer added together. The Overview spend tile shows one figure per currency, names the currency in its label when there is one, and gives a change against the previous window only when both windows are in the same currency. A run's dashboard cost is left out when the run is priced in more than one currency, and the run detail page totals each currency apart. Unpriced entries add nothing, so a run with only those shows a dash, not zero.
Checking costs against a bill
New in v1.6.0. Every metered call leaves one cost entry, so the ledger can be read the way a provider's bill is: per key, per principal, per model or tool, per day. Each entry has a pricing status:
| Status | Meaning |
|---|---|
priced | An amount computed from what the provider reported |
estimated | An amount computed from usage SOIT estimated (usage_estimated; see Charges SOIT estimates) |
free | An explicit price of zero |
unpriced | No amount, because no price applied; unpriced_reason says why, such as pricing_not_configured, image_variant_not_priced, or tool_pricing_not_declared |
An unpriced entry is never counted as zero, and amounts in different currencies are never added together.
GET /api/v1/runs/costs/entries?since=…&until=…&api_key_id=…&pricing_status=unpriced
GET /api/v1/runs/costs/reconciliation?since=…&until=…&group_by=model- Both take the same filters: the run's
api_key_id,user_id(a member or a service principal), andsource(platformorgateway), and the entry'smodel_ref,provider_slug,tool_ref,source_port,operation,currency, andpricing_status.upstream_idandupstream_request_idfilter too, so a line of a provider's request log finds its entry. /costs/entrieslists entries oldest first, each with its run'sapi_key_id,user_id, andrun_source, itspricing_status, and, when unpriced, itsunpriced_reason. Itsuntilis inclusive, as before. It pages withpage_tokenandpage_size(200 by default, at most 500)./costs/reconciliationcovers the half-open window[since, until), like the ledger exports. It answers the priced total per currency (amounts), the part of it computed from estimated usage (estimated_amounts), the entries per status (status_counts), and the unpriced entries per reason (unpriced_reasons). Withgroup_by(model,provider,tool,api_key,user,source,operation, orday) it adds one row per value and currency, with unpriced entries in a row of their own; at most 500 rows, andgroups_truncatedsays when there were more.external_reconciliationis alwaysnot_performed: this is the ledger's side of the check only.- Entries are dated when they were written. A charge the image job reaper records for a lost call is dated when the reaper ran, so it can fall in a later window than the call.
- Providers' usage exports are mostly per hour or day, so most bills are still matched by model, key, and time window; the provider ids help where a provider's request log is at hand.
- Workspace readers can call both, as they can the other
/api/v1/runs/costsreads.
In the console, Observe › Costs shows the reconciliation for the last 24 hours, 7 or 30 days, or this month: one figure per currency with the estimated part apart, the unpriced entries counted and listed by reason, and a table grouped by model, provider, tool, API key, principal, or day, filtered by pricing status and source. Export CSV downloads the grouped rows to set beside a bill. The page says the provider's bill itself has not been checked.
Daily usage aggregates
The usage_daily_aggregates table sums metered calls, tokens, and priced amount per UTC day, source, user or service principal, key, provider, model, and operation. Rehearsal runs are kept apart as source rehearsal.
- A
cost.recordedconsumer adds each usage fact as it arrives, exactly once. - A reconciler in the outbox dispatcher process rebuilds each finished day from the cost ledger, every
USAGE_RECONCILE_INTERVAL_SECONDS(hourly by default). - The two never count a fact twice.
Budgets
A budget limits spend for one scope, per UTC day or month, in one currency:
| Scope | Limits |
|---|---|
| Workspace | Everything the workspace spends |
| API key | Calls made with one key |
| Member or service principal | One user's or one principal's spend |
| Agent | One agent's spend |
Workspace owners and admins manage budgets under Govern › Budgets or with /api/v1/billing/budgets. The console lists every budget with its spend in the current period against the limit, its forecast, when it resets, and what happens at the limit, and flags the ones at risk, spent, or on course to overrun.
Spend is read from the daily aggregates for completed days and from the cost ledger for today. GET /api/v1/billing/budgets/{budget_id}/status reports one budget's spend, remainder, percentage, and forecast to the end of the period; GET /api/v1/billing/budgets/statuses reports every budget in one call.
Hard stop
A budget with a hard stop refuses model and tool calls once spent, with 402 naming the budget, what it spent, and when it resets. The refusal is written to the audit ledger.
| Entry | Refusal |
|---|---|
/api/v1 | 402, code BUDGET_EXHAUSTED |
/v1 (gateway) | 402, OpenAI error type insufficient_quota, code budget_exhausted |
/mcp | A tool result with isError: true and the reason, so the model can act on it |
The gateway also answers 402 (insufficient_quota) when credit is exhausted.
Calls with no price
New in v1.6.0. A call to a model or tool with no price configured is recorded without an amount, so no budget counts it and a hard-stop budget cannot stop it. The workspace setting Calls with no price in Settings › Security (unpriced_call_policy on PATCH /api/v1/workspaces/{workspace_id}, changed by workspace admins) decides what such a call does:
| Value | Effect |
|---|---|
allow | The call goes ahead, unpriced, as before. The default, and what existing workspaces keep |
refuse_when_budgeted | Refused while a hard-stop budget applies to the call |
refuse | Always refused |
- A refused call is answered before the provider or the tool is called, naming the model or tool:
403with codePRICING_NOT_CONFIGUREDon/api/v1, and403permission_errorwith codepricing_not_configuredon the/v1gateway. - The refusal is written to the audit ledger as
billing.unpriced.blocked. - A virtual model moves on to a priced target.
- A refused model call gives back the budget hold its budget check took, instead of keeping it until the hold expires.
- An image is priced by its size and quality, so a model priced for one request can be unpriced for another.
- To keep a local model callable under either refusal, give it an explicit zero price (
{"currency": "USD", "input": 0, "output": 0, ...}).
Holds for calls in flight
Each admitted call holds its budgets for the period's average call cost until its cost is committed, so concurrent callers overshoot a limit by about one call at most. Holds live in Redis.
New in v1.4.0:
- Checking a budget and holding it are one Redis step, so two concurrent callers can no longer both take the last slot.
- A hold is released as soon as the call's cost is committed, instead of lasting a minute, so a busy workspace is no longer refused near its limit for calls that already finished.
- A failed call's hold is released when its run ends. A hold that nothing releases lasts the call's timeout and a minute, so a long image call keeps its budget held to the end.
- While Redis is unreachable, each process keeps its own holds instead of skipping them.
In v1.3.0 a hold expired after about a minute rather than being released when the call finished, and holds were skipped without Redis.
Threshold alerts
Crossing a threshold (50, 80, and 100 percent by default) publishes one billing.budget.threshold_reached event per budget, period, and threshold. Workspace owners and admins are notified in their inboxes, on their own email and webhook endpoints as their delivery preferences allow, and on the workspace's team channels subscribed to the alert category. Team channels are managed under Settings › Notifications or with /api/v1/notifications/workspace-endpoints.
New in v1.4.0. Each cost is judged against the spend as of that cost, so a threshold is announced even when the outbox dispatcher runs behind. Before, a threshold was judged against the spend when the cost event was handled, so costs recorded meanwhile could carry spend past a lower threshold before its event was read, and that threshold was never announced.
Aggregates, thresholds, and alerts are worked out by a process with the default role. A gateway-only process runs none of that background work, so at least one process with the default role must run beside it.
Evidence of spend
- Cost entries leave SOIT in the versioned ledger contract through
GET /api/v1/exports/costs, with amounts as decimal strings so no reader rounds them. - New in v1.6.0. The contract is version
1.1: cost records may carry the optionalupstream_idandupstream_request_id. Evidence bundles and ledger exports declare1.1, so a consumer that rejects unknown fields must accept the two. - A run's evidence bundle includes its cost records (
costs.jsonl). - Budget refusals are audit events, so the audit export includes them.
API and UI surfaces
| Method | Path | Purpose |
|---|---|---|
| GET / POST | /api/v1/billing/budgets | List and create budgets |
| GET / PATCH / DELETE | /api/v1/billing/budgets/{budget_id} | Read, edit, or delete a budget |
| GET | /api/v1/billing/budgets/{budget_id}/status | One budget's spend, remainder, percentage, and forecast |
| GET | /api/v1/billing/budgets/statuses | Every budget's status |
| GET | /api/v1/runs/costs/... | Cost breakdowns, filterable by source and api_key_id |
| GET | /api/v1/runs/costs/entries | Cost entries with their pricing status, filterable as in Checking costs against a bill (filters and row fields new in v1.6.0) |
| GET | /api/v1/runs/costs/reconciliation | New in v1.6.0: totals per currency, entries per status, unpriced entries per reason, and group_by rows for a half-open window |
| PATCH | /api/v1/workspaces/{workspace_id} | New in v1.6.0: unpriced_call_policy (allow, refuse_when_budgeted, refuse) |
Console: Govern › Budgets, Observe › Runs, Observe › Costs, Settings › API, Settings › Security, Settings › Notifications.
Known limitations
As of v1.6.0:
-
Cost reconciliation lines the ledger up beside a bill; SOIT does not import or read provider bills, and the console says the bill itself has not been checked.
-
A call that fails, or records no cost, keeps its budget hold until its run ends (for a gateway call, when the call returns), or at most its timeout (
LLM_TIMEOUT_SECONDS, orLLM_IMAGE_TIMEOUT_SECONDSfor images) and a minute. A run that never ends on the process that admitted the call releases the hold only when it expires, and a call still running after that, such as a very long stream, no longer holds its budget. -
While Redis is unreachable, replicas do not see each other's holds and can each overshoot a budget by about one call.
-
Budget spend reads the daily aggregates for finished days and the cost ledger for today; a day's aggregate is rebuilt by the reconciler (
USAGE_RECONCILE_INTERVAL_SECONDS, hourly by default).
Related pages
- OpenAI-Compatible Gateway — per-key limits and service principals.
- Evidence and Exports — taking cost records out.
- Notifications — where threshold alerts land.
- Tasks and Runs — the ledger cost entries belong to.