Skip to main content

Usage

Usage events record the cost of every metered occurrence, with the measured quantities held in per-dimension component rows, so spend can be attributed to a project, agent, and generation.

Overview

Every metered occurrence writes one usage event plus its component rows: an event captures attribution and total cost; each component captures one priced dimension. Four meter types share the shape — llm_tokens, compute_execution, storage, and api_request. Events and components are append-only and immutable, and writes are idempotent, so historical usage never changes and a replayed completion never double-counts. Every event links back to the generation, agent, trace, AI provider, project, and — when applicable — the trigger or orchestration run behind it.

See the Permissions Reference for the IAM action strings for this module.

Data Model

UsageEvent

FieldTypeDescription
idstringPublic identifier for the usage event (ue_ prefix)
project_idstringProject the usage is attributed to
orchestration_run_idstring | nullOrchestration run that initiated the occurrence, when it ran inside a run
node_idstring | nullOrchestration node within the run, when applicable
agent_idstring | nullAgent that ran the generation
generation_idstring | nullGeneration this usage was recorded for
trace_idstring | nullTrace this usage belongs to (reconcile against the trace tree)
actor_idstring | nullActor (end user) the occurrence was produced for; null when no end user is behind the work
session_idstring | nullSession the occurrence ran in; null when not dispatched through a session
ai_provider_idstring | nullAI provider instance billed; correlates the event to the price book. On a routed generation this is the target the route actually picked, not the agent's binding (a routed agent pins no provider)
trigger_idstring | nullTrigger that initiated the generation (agent-target triggers); null otherwise
action_idstring | nullCaller-supplied logical action label, for rolling spend up per action
sourcestring | nullThe workload behind the spend when it is not ordinary agent traffic; see Workload source
meter_typestringWhat the event measures: llm_tokens, compute_execution, api_request, or storage
providerstringAs-billed SKU vendor slug (e.g. openai); soat for platform meter types
modelstringModel identifier the provider billed; the billable SKU for platform meter types
cost_usdnumber | nullTotal cost in USD — the sum of the priced component costs, frozen at write time; null when nothing is priced
componentsarrayThe priced dimensions of this event (see UsageComponent)
created_atstringISO 8601 creation timestamp

UsageComponent

One priced dimension of an event: quantity is always in unit, and cost_usd = quantity × unit_price.

FieldTypeDescription
componentstringThe measured dimension: input_tokens, output_tokens, cached_tokens, reasoning_tokens, compute_second, request, gb_day, …
quantitynumberThe measured amount, expressed in unit
unitstringUnit quantity is measured in (token, compute_second, request, gb_day)
billablebooleanWhether the component contributes to cost. reasoning_tokens (a subset of output_tokens) is non-billable and excluded from cost and billable totals
unit_pricenumber | nullUSD per unit, frozen at write time; null when unpriced
cost_usdnumber | nullquantity × unit_price, frozen at write time; null when unpriced
price_idstring | nullPrice-book row that produced unit_price/cost_usd; null when unpriced

PriceBook

A versioned unit price for one billable component of a SKU. Three scopes live in one table, resolved most-specific first: a per-provider override (ai_provider_id set), a project + provider-slug price (project_id set, ai_provider_id null), and a global default (both null). Within each scope the latest effective_from <= now() applies.

FieldTypeDescription
idstringPublic identifier for the price row (price_ prefix)
ai_provider_idstring | nullSet for a per-provider override; null otherwise
project_idstring | nullSet for a project + provider-slug price; null otherwise
meter_typestringMeter type this SKU belongs to (llm_tokens, compute_execution, …)
providerstringSKU vendor slug (e.g. openai); soat for platform SKUs
modelstringModel identifier, or the billable SKU for platform meter types
componentstringThe component this row prices (input_tokens, compute_second, …)
unitstringUnit unit_price is denominated in (token, compute_second, …)
unit_pricenumberUSD per unit (for token components, USD per token)
effective_fromstringISO 8601; the latest row <= now() prices a call
created_atstringISO 8601 creation timestamp

UsageThreshold

A per-project alert rule on windowed usage. When the project's metric over window crosses threshold, a usage.threshold_crossed webhook fires. Thresholds are immutable apart from deletion — to change one, delete and recreate it (which resets its fire state).

FieldTypeDescription
idstringPublic identifier for the threshold (uthr_ prefix)
project_idstringProject the threshold applies to
metricstringcost_usd (across all meter types) or tokens (input + output + cached)
windowstringcalendar_month (current UTC month) or rolling_24h (trailing 24 hours)
thresholdnumberThe value the windowed aggregate must cross to fire (> 0)
last_fired_atstring | nullWhen it last fired; null until the first fire
fired_window_keystring | nullYYYY-MM key of the last fire (calendar_month hysteresis); null for rolling_24h
created_atstringISO 8601 creation timestamp

Key Concepts

Meter types and components

meter_typeWhat one event recordsComponents
llm_tokensOne completed LLM call's token usageinput_tokens, output_tokens, cached_tokens, reasoning_tokens
compute_executionWall-clock compute time of a unit of work (orchestration node, agent generation, tool call)compute_second
api_requestA batch of API requests served for a projectrequest
storageOne project's stored bytes for one daygb_day

For platform meter types the (provider, model) pair is a SKU: provider is soat and model names the billable unit (e.g. compute-second, gb-day, request).

Token components are disjoint and additive: input_tokens is the uncached input, so full prompt tokens = input_tokens + cached_tokens. reasoning_tokens is a non-billable subset of output_tokens. Cached and reasoning components are recorded only when the provider reports them.

Coverage

Every LLM call the platform makes is metered, through one shared choke point:

PathMetered callsEvent attribution
Agent generationsAgent generate (non-streaming, streaming, and the tool-outputs continuation), conversations, and orchestration agent nodesFull chain: generation_id, agent_id, trace_id, plus orchestration_run_id/node_id inside a run
Standalone completionsChat completions (stateless and chat-scoped) and memory fact extraction and consolidationgeneration_id and trace_id are null — these calls create no generation. agent_id is set for memory passes, null for chats

Idempotency keys: inside a run the key is scoped to the node execution attempt (run:<orchestration_run_id>:node:<node_id>:attempt:<n>), so a replayed node is a no-op while a retry meters for real — a second attempt is a second generation that reached the provider, and dropping it would under-report the node. The same identity keys the compute_execution meter and the node-execution record, so all three agree on what one attempt is. Standalone completions have no replay identity, so their key is unique per call (completion:<source>:<uuid>). A streamed completion is metered when the stream finishes; a stream the client abandons mid-way is not metered.

A turn that ends failed is metered when it spent something. The case that matters is a generation the model answered — the text just did not satisfy the agent's output_schema, so the turn fails with OUTPUT_SCHEMA_VALIDATION_FAILED — because the provider billed for those tokens either way and the counts come back on the failure. A request that never reached the model (a provider 4xx/5xx, a network fault) burned nothing and writes no event, so a failed generation with no usage row means the call never landed rather than that metering was skipped.

Compute metering

Every orchestration node execution that actively ran writes one compute_execution event carrying a compute_second component with the node's wall-clock seconds (completed_at − started_at). Non-agent nodes still meter compute; an agent node produces both an llm_tokens and a compute_execution event. Attribution is at the run/node level (generation_id, agent_id, trace_id are null). Priced from a soat/compute-second SKU when one is effective; idempotent on compute:<orchestration_run_id>:node:<node_id>:attempt:<n>. A skipped node is not metered.

Storage metering

A daily snapshot writes one storage event per project per UTC day, carrying a gb_day component with the project's stored gigabytes — uploaded file sizes plus document chunk text, summed at snapshot time. No principal/agent/run attribution. Priced from a soat/gb-day SKU; idempotent on storage:<project>:<YYYY-MM-DD>. Intra-day churn between samples meters zero.

API-request metering

Requests are counted in memory per (project, API key) and a periodic flush writes one api_request event per counter per window — deliberately never one row per request. Counting scope mirrors quotas exactly: only API-key-authenticated requests count, a project-scoped key counts against its bound project, an unscoped key against the project the route resolved and authorized, and a request that resolves to no single project is not counted. Enforcement stays with quotas — this only prices (from a soat/request SKU). The flush-window idempotency key includes a per-instance id; the last still-open window is lost on an unclean shutdown (a bounded undercount).

Trigger and action attribution

action_id is a caller-supplied label passed on the generate request, persisted on the generation and copied onto its event. trigger_id is set automatically when a trigger initiates the generation — directly or via an orchestration run the trigger started. Filter the event list by either (?trigger_id= / ?action_id=).

Workload source

source names the workload that produced the spend, so verification and background work are separable from user-serving traffic:

sourceWhat produced the event
nullAn ordinary agent generation
evalAn eval run's item generations
eval_judgeAn llm_judge scorer's own grading completion
chatA standalone chat completion
memory_extraction / memory_consolidationA memory pass

source is set by the platform at the metering choke point — a caller cannot bill eval spend as production. It both filters (GET /api/v1/usage/meters?source=eval) and groups (group_by=source); ordinary traffic collapses into the null bucket, so groups still sum to the project total.

Provider attribution

An event bills against the provider that served it: the target a model route picked for the turn, or the agent's pinned provider when it has one. The route's choice is read from what the turn actually did, so a generation that failed over meters against the target that answered rather than the one it abandoned. Because a routed agent pins nothing, this is also what keeps routed spend priced at all — an event with no provider resolves no price row and reports the unknown provider slug.

End-user attribution

An event carries the actor and session it was produced for, copied from the generation at write time and frozen — renaming or deleting either never rewrites recorded spend. Attribution is set on the session path only; direct agent generations, trigger-initiated work, orchestration nodes, and standalone completions record null for both. The actor is derived from the session, never taken from the request (tool_context is caller-writable and is not read for attribution). Both dimensions filter (?actor_id= / ?session_id=) and group (group_by=actor / group_by=session). Events recorded before this shipped carry null.

Pricing

Each component's cost is computed at write time from the effective price row for its (provider, model, component), resolved most-specific first: AI provider instance → project + provider-slug → global default. Costs are frozen onto the components; later price changes never alter them. cached_tokens falls back to the input_tokens rate when no cached price is set. A null cost_usd means no price row covered the component — the quantity is still captured. Each component records price_id, so a receipt is auditable to the precise price applied.

SOAT ships no default prices. Prices are managed where their scope lives:

Past-effective prices are immutable — corrections ship as new future-dated rows. A first price is the exception: when nothing prices a (provider, model, component) yet, in the scope being written or any broader one it resolves through, effective_from may be now or earlier. There is no row to rewrite and no cost frozen against one, and forcing a future date would leave the scope live and unpriced until it lands — a component metered in that window is charged null permanently, since cost is frozen when the event is written. Prices can also be declared in a formation with the project_price resource type, keyed on (provider, model, component, effective_from); there effective_from is optional and defaults to deploy time. See Formations Types → Project Price.

Receipts and reconciliation

GET /api/v1/usage/receipt?generation_id=… returns a billing receipt for a completed generation: one line item per usage event, a by_meter_type cost split, reconstructed token totals (total_input_tokens is uncached input + cached), and a grand total. Because every component carries its price-book version and frozen cost, receipts are reproducible and meant to reconcile against the provider's invoice within a small tolerance (target ±2%).

GET /api/v1/usage/receipt?orchestration_run_id=… returns the same shape for an entire orchestration run, summed across every node. The run's roll-up is also surfaced inline as a usage object on GET /api/v1/orchestration-runs/{orchestration_run_id}.

Every line item carries the node_id that produced it, so a run receipt is also the per-node cost breakdown — group the lines by node_id and each node's spend is the sum of its lines. Both meters appear under the node: an agent node's llm_tokens line and the compute_execution line of every node execution, so a pure node (a transform, a condition) shows up with its execution cost alone. Two things to know when reading it:

  • A retried node's attempts share one node_id. Each attempt meters as its own event, so a retried node contributes one line per attempt; the event itself records no attempt number, so the lines group under a single node_id. That is the intended reading for spend — a retry is real money, so it belongs in the node's total.
  • A null node_id means no node produced the event: a standalone generation on a per-generation receipt, or a run-level meter.

A run whose graph contains a loop or sub_orchestration node is covered by the receipt only for its own nodes: those nodes start child runs, whose events are attributed to the child, so the parent's receipt shows the starting node's execution cost and not what the children spent. That is deliberate — the line items carry a node_id, and merging a child's nodes in would mix node ids from two graphs under one list. The run's own usage field does span the subtree, so read that for the delegated total, usage_own for the run's own nodes, and the parent_orchestration_run_id filter for the children themselves; all three are described in Run usage.

Aggregation

GET /api/v1/usage?project_id=…&group_by=… rolls a project's usage up over an optional [from, to] window (inclusive ISO-8601 bounds on created_at), bucketed by one dimension — model, ai_provider, agent, run, day, meter_type, actor, session, or source. ai_provider buckets on the provider the spend was billed against (see Provider attribution). Each group and the grand totals carry summed token counts and cost_usd (null when no event in the bucket was priced). An event a dimension does not apply to collapses into a null-keyed group, so groups always sum to the project total. Requires usage:GetUsage on the project.

Every group also carries a components array — the measured dimensions summed over the bucket — so an infra meter aggregates to what it measured rather than reading as all-zero tokens. Entries are keyed by component and unit and sorted, and quantities are summed as exact decimals (no float drift).

For platform meter types group_by=model mixes model ids with SKUs; add meter_type=llm_tokens (or another meter type) to narrow to one meter. The applied filter is echoed back as meter_type on the response; an unrecognized value yields an empty rollup rather than an error.

Under group_by=model every group also carries ai_provider_id — the AI provider that served the bucket's model, null on every other dimension. A model id does not identify its provider on its own: one project can hold two providers serving byte-identical model names, so a consumer that presents its own model names cannot translate a bucket it cannot attribute. The model dimension therefore buckets on the model id and its provider, so one model name served by two providers is two groups repeating the same key with different ai_provider_id. The groups still sum to totals.

Spend guards

Metered usage feeds the guardrail evaluator's runtime.usage.* context, so a spend limit is enforced deterministically at the tool boundary:

  • Per project, windowedruntime.usage.cost_usd_{1h,24h,7d,30d} and runtime.usage.tokens_{24h,30d}.
  • Per run, cumulativeruntime.usage.run_tokens and runtime.usage.run_cost_usd; see per-run spend ceilings.

Both read live at evaluation time and fail closed. Unlike thresholds, which alert, a guard aborts the call.

Thresholds and alerts

After each usage-event write, every UsageThreshold on the event's project is evaluated against its windowed aggregate, and a usage.threshold_crossed webhook fires for any that cross. Re-fire hysteresis:

  • calendar_month — fires at most once per window; fired_window_key blocks re-fire until the YYYY-MM key changes.
  • rolling_24h — re-arms only once the value drops below 90% of the threshold, then may fire again.

The webhook payload (data) is:

{
"threshold_id": "uthr_V1StGXR8Z5jdHi6B",
"project_id": "proj_V1StGXR8Z5jdHi6B",
"metric": "cost_usd",
"window": "calendar_month",
"window_key": "2026-07",
"threshold": 100,
"observed_value": 101.37
}

window_key is null for rolling_24h. Subscribe a webhook to usage.threshold_crossed (or usage.*) to receive it.

Configuration

Environment VariableRequiredDescription
USAGE_STORAGE_SNAPSHOT_INTERVAL_MSNoStorage-snapshot interval (default daily).
USAGE_STORAGE_SNAPSHOT_DISABLEDNotrue disables the storage snapshot.
USAGE_REQUEST_FLUSH_INTERVAL_MSNoAPI-request counter flush interval (default 60000). A freshness-vs-row-volume trade-off.
USAGE_REQUEST_METERING_DISABLEDNotrue disables API-request metering (middleware stops counting and the flush timer stops).
SOAT_INSTANCE_IDNoPer-instance id folded into the request-flush idempotency key so multiple instances don't collide (falls back to HOSTNAME, then default).

Examples

List a generation's raw meter rows:

soat list-usage-meters --generation-id gen_V1StGXR8Z5jdHi6B

Get a generation's receipt (pass orchestration_run_id instead for a whole run, whose lines carry node_id):

soat get-usage-receipt --generation-id gen_V1StGXR8Z5jdHi6B

Aggregate a project's usage by meter type over a window:

soat get-usage \
--project-id proj_V1StGXR8Z5jdHi6B \
--group-by meter_type \
--from 2026-07-01T00:00:00Z \
--to 2026-08-01T00:00:00Z

Create a usage threshold (alerts when monthly cost crosses 100 USD):

soat create-usage-threshold \
--project-id proj_V1StGXR8Z5jdHi6B \
--metric cost_usd \
--window calendar_month \
--threshold 100