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 (attribution, total cost) plus component rows (one priced dimension each). Five meter types share the shape: llm_tokens, compute_execution, storage, api_request, tool_execution. Events and components are append-only and immutable; writes are idempotent, so a replayed completion never double-counts. Each event links to its generation, agent, trace, AI provider, project, and, when applicable, trigger or orchestration run.

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
tool_idstring | nullTool a tool_execution event metered; null on every other meter and for an inline tool
publisher_project_idstring | nullProject that owns the resource this event metered, when the call reached it through a share; null for a call on the project's own resources
document_idstring | nullDocument an embedding event embedded a chunk of; null on a query embedding and every other meter
memory_store_idstring | nullMemory store an embedding event embedded written content for; null on a query embedding and every other meter
outcomestring | nullHow a tool_execution call ended: ok, error or timeout; null on every other meter
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, storage, or tool_execution
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, cache_write_tokens, reasoning_tokens, compute_second, request, gb_day, chunk_count, record_gb_day, …
quantitynumberThe measured amount, expressed in unit
unitstringUnit quantity is measured in (token, compute_second, request, gb_day, count)
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)
resourcestring | nullThe tool a resource row prices, as an SRN; null for a SKU row
quantityobject | nullA resource row's JSON Logic quantity; null prices one per call
effective_fromstringISO 8601; the latest row <= now() prices a call
created_atstringISO 8601 creation timestamp

UsageThreshold​

A per-project alert rule: when metric over window crosses threshold, a usage_thresholds.crossed webhook fires. Immutable apart from deletion; delete and recreate to change one (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 turn segment's token usage — a four-step turn is four LLM calls and one event, written when the turn ends. A turn that pauses on a client tool writes one event per segment, each when it pauses or endsinput_tokens, output_tokens, cached_tokens, cache_write_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 footprint for one daygb_day, chunk_count, record_gb_day
tool_executionOne outbound tool calltool_call (quantity 1)

For platform meter types (provider, model) is a SKU: provider is soat, model the billable unit (compute-second, gb-day, request, tool-call).

Token components​

Token components are disjoint and additive. The provider reports one prompt figure covering three different prices, and SOAT splits it into three components so each is priced at the rate it is actually charged at:

ComponentWhat it countsHow it prices against uncached input
input_tokensPrompt tokens neither read from nor written to the provider's prompt cachethe baseline
cached_tokensPrompt tokens served from the cachefar cheaper
cache_write_tokensPrompt tokens written into the cachedearer

So full prompt tokens = input_tokens + cached_tokens + cache_write_tokens, and input_tokens is uncached input alone — never the provider's total. reasoning_tokens is a non-billable subset of output_tokens. The cache and reasoning components are recorded only when the provider reports them, so a call that cached nothing carries neither.

Cache activity appears only for an agent that asked for it — see Agents — Prompt Caching.

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: a generation's first segment is keyed by its id (<generation_id>), a segment resumed after <n> steps by <generation_id>:step:<n>, so a replayed segment is a no-op and a resume meters only the calls it makes. Inside a run the generation's part is 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; the same identity keys the compute_execution meter and the node-execution record. Standalone completions have no replay identity: completion:<source>:<uuid>. A streamed completion is metered when the stream finishes; one the client abandons is not.

A failed turn is metered for every provider call it completed: the steps before the one that failed, an attempt abandoned for the retry without tools, an answer refused afterwards (OUTPUT_SCHEMA_VALIDATION_FAILED, TEXT_ENCODED_TOOL_CALL, a failed client-call gate on the initial turn or a tool-outputs continuation), and the steps of a turn a newer session message preempted. A call that returned no usage (provider 4xx/5xx, network fault, a model-route attempt aborted at its timeout, a stream the client abandoned) writes no event, so a failed generation with no usage row means no call completed. A usage event the server could not write is logged at error level, never dropped silently.

Billed but unmetered​

Two calls are cut off before the provider reports usage. The provider still bills them, the input in full and the output generated so far, and neither writes a usage event or an estimate:

CallWhat stays visible
A model-route attempt aborted at its timeout_secondsThe attempt in the generation's routing.attempts, error_class: timeout
The in-flight call of a session turn a newer message preemptedThe turn's completed steps, metered as above

A provider invoice therefore exceeds /usage by these calls; reconcile a route with frequent timeouts from its routing.attempts.

Compute metering​

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

Tool executions​

Every outbound tool call writes one tool_execution event: one tool_call component of quantity 1, unpriced, plus one component per resource row the tool's owner prices it with. With no resource row, cost_usd is null. It is recorded at the three protocol primitives — the http request, the mcp tools/call, the builtin action — and at the pipeline runner, which every server-side execution passes through: a direct POST /api/v1/tools/{tool_id}/call, a tool the model calls inside a generation, an orchestration tool or poll node, a trigger targeting a tool, a workflow task dispatch, an eval tool scorer, a guardrail context tool and an ingestion converter.

  • When the call went out, whatever it answered. outcome is ok, error (a non-2xx answer, an MCP error result, a transport failure) or timeout, so failures are counted without a second meter.
  • Not an execution: a call refused before it was sent (a guardrail decision other than execute, an egress block, an approval pending, an unresolvable template), and a client tool, which never runs server-side.
  • A pipeline counts once for itself and once per step: a step-less pipeline writes one event, a two-step one three. Its own event's outcome is the pipeline's. A pipeline refused before it runs (an invalid definition, PIPELINE_DEPTH_EXCEEDED) writes none.
  • Attribution is what the call site holds: tool_id and project_id always; inside a generation its generation_id, agent_id, run, node, trigger, actor, session and source are read off the generation; an orchestration node sets orchestration_run_id and node_id; a trigger sets trigger_id; an eval tool scorer sets source: eval_scorer.
  • No replay identity: a retry is a second call on the wire, so the idempotency key is unique per execution (tool:<uuid>).

GET /api/v1/usage/aggregate?meter_type=tool_execution&group_by=tool counts calls per tool; tool_id and outcome narrow it.

Calls through a share​

A call on a tool another project shares with this one is metered here, in the calling project: its quotas, thresholds and /usage count it. The event names the publisher in publisher_project_id and the publisher's tool in tool_id; the publisher's project records nothing of the call. publisher_project_id narrows both GET /api/v1/usage/events and the aggregate, and tool_id accepts a shared tool's id. A call refused before it is sent is not metered, as for any tool.

A turn of an agent another project shares with this one is metered the same way: its llm_tokens events belong here, with publisher_project_id naming the publisher, agent_id its agent and ai_provider_id its provider, priced from the publisher's price book. The agent's own tool calls in that turn are calls through a share too. Guardrails read the same events live through runtime.<module>.tool_calls.<window> and runtime.<module>.errors.<window>.

Storage metering​

A daily snapshot writes one storage event per project per UTC day with three components: gb_day (stored gigabytes) and chunk_count (indexed rows behind them), measured in one statement, and record_gb_day (gigabytes of records of work done). No principal/agent/run attribution. Each is priced from the soat/gb-day SKU from its own component row; the event's cost is their sum. Idempotent on storage:<project>:<YYYY-MM-DD>, so the run at server startup re-samples the current day; intra-day churn meters zero; an unpriced component records its quantity with cost_usd null. A project whose snapshot cannot be written is logged at error level; only a later sweep inside the same UTC day writes that day.

gb_day sums seven terms:

TermSource
Uploaded file bytesfiles.size
Chunk textdocument chunk content
Chunk embeddingsthe stored width of each chunk's vector
Memory entry textmemory entry content
Memory entry embeddingsthe stored width of each entry's vector
Dataset item payloadsdataset item input, expected_output, metadata
Eval result payloadseval result input, expected_output, scores, output

chunk_count counts document chunks plus memory entries, embedded or not (a row joins the vector index once its embedding is written). Dataset items and eval results carry no vector and are not counted.

  • The evaluations corpus grows with runs, not the dataset. An eval result freezes its own copy of the item it scored: a 100-item dataset run ten times stores eleven copies of every payload. Retention clears a result's output only.
  • The snapshot bounds the corpus. A storage_bytes quota caps the footprint against the newest storage event plus the request's delta, refusing corpus writes with 409 QUOTA_STORAGE_EXCEEDED; this keeps the check off the per-upload path at up to a day of staleness.
  • Embeddings dominate. Four bytes per dimension: at EMBEDDING_DIMENSIONS=1024 one embedding is ~4 KB against ~1 KB of text. A row without an embedding contributes its text only. Vector widths are measured from the stored value, not computed from EMBEDDING_DIMENSIONS.
  • Physical overhead is excluded from gb_day. Index pages (including the HNSW graphs over both vector columns), TOAST chunk and tuple headers, and table bloat are not counted: none is attributable to one project and it moves with vacuum state. Real disk use is higher by a deployment-dependent factor.
  • chunk_count is what that overhead is priced against. Most of a chunk's cost is fixed per row: at EMBEDDING_DIMENSIONS=1024 an HNSW element occupies a whole 8 KiB page (4 KB vector plus neighbour list) on top of the ~5.5 KB stored out of line. On a mirrored schema, a 25× change in chunk size moves a chunk's cost by 17%, while the same corpus re-chunked meters between 2.2× and 7.4× its source size on gb_day. A count does not drift with the caller's chunk_strategy, and distinguishes a few large documents from a million tiny chunks, alike on gb_day and unlike on search.

Records of work​

record_gb_day is the gigabytes the runtime writes as work runs, beside what a caller stored:

TermSource
Generationsevery column of each generation, including its recorded input messages
Tracesevery column of each trace row
Usage eventsevery column of each usage event and its components, this meter's own included
Audit entriesevery column of each audit entry scoped to the project
Activity entriesevery column of each activity entry
  • Disjoint from gb_day. Trace steps are stored as a file under /.system/traces/, which gb_day sums with the other files; the trace row is all record_gb_day adds. Summing the two never counts a byte twice.
  • Not under the storage_bytes quota. The quota reads gb_day alone, so run history never refuses a corpus write.
  • Stored width. Each column is measured as PostgreSQL stored it, so a compressed transcript counts its compressed size. Physical overhead is excluded, as for gb_day.
  • Shrinks with retention. A content purge or the retention sweep clears a trace's and its generations' content, and the next snapshot reads the skeleton that remains. Audit entries past the deployment's retention window are deleted.
  • Unpriced by default. No price row ships for it; the quantity is recorded with cost_usd null until a deployment adds one.

API-request metering​

Requests are counted in memory per (project, API key); a periodic flush writes one api_request event per counter per window, never one row per request. Counting scope mirrors quotas: only API-key-authenticated requests count, a project-scoped key against its bound project, an unscoped key against the project the route resolved and authorized, none when no single project resolves. Enforcement stays with quotas; this only prices (soat/request SKU). The flush-window idempotency key includes a per-instance id; the last open window is lost on an unclean shutdown (a bounded undercount). A counter whose write fails is logged at error level and stays buffered, so the next flush writes its count.

Trigger and action attribution​

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

Workload source​

source names the workload behind the spend, separating verification and background work 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
eval_scorerA tool scorer's grading call
chatA standalone chat completion
memory_extraction / memory_consolidationA memory pass
embeddingAn embedding call — the endpoint, document ingestion, a memory write, or a search's query vector

Set by the platform at the metering choke point; a caller cannot bill eval spend as production. Filters (GET /api/v1/usage/events?source=eval) and groups (group_by=source); ordinary traffic is the null bucket, so groups sum to the project total.

Provider attribution​

An event bills against the provider that served it: the target a model route picked (read from what the turn did, so a failed-over generation meters against the target that answered), or the agent's pinned provider. Its model is the model that provider served — a routed target's own model, else the id the provider answered with (a versioned id included), else the configured one — because that pair is the price-book key, on success and failure alike. An event with no provider resolves no price row and reports the unknown provider slug.

Attribution outlives deletion​

Every attribution id on an event — agent_id, generation_id, trace_id, orchestration_run_id, actor_id, session_id, ai_provider_id, tool_id, publisher_project_id, document_id, memory_store_id — is recorded at write time and kept when the resource it names is deleted. The event listing still names it, group_by still buckets its spend under it, and a filter on it still selects that spend.

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 spend. Set on the session path and on a document ingest that names session_id or actor_id; direct agent generations, trigger-initiated work, orchestration nodes, and standalone completions record null for both. Where there is a session, the actor is derived from the session, never from the request (tool_context is caller-writable and not read); an ingest may name an actor alone only with no session.

QuestionRead
What did this conversation cost?The usage object on GET /api/v1/sessions/{session_id} — see Session cost
What has this end user cost, over a window or split by day/model?GET /api/v1/usage/aggregate with actor_id= (or session_id=), with or without a group_by
Which sessions or users are the biggest spenders?The same endpoint with group_by=session or group_by=actor
Which turns made up that spend?GET /api/v1/generations with session_id= or actor_id=

session_id and actor_id are two of the thirteen narrowings; both narrow the whole rollup and compose with any group_by. actor_id is the figure behind an actor-scoped cost_usd quota. The same pair is on the generation record (GET /api/v1/generations/{generation_id}), filters the generation listing, and filters the raw event listing (?actor_id= / ?session_id= on GET /api/v1/usage/events), so a consumer can walk session → generation → components without recording the link itself.

Pricing​

Each component's cost is computed at write time from the effective price row for its (provider, model, component), most-specific first: AI provider instance → project + provider-slug → global default. Costs are frozen; later price changes never alter them. cached_tokens and cache_write_tokens each fall back to the input_tokens rate when no row of their own is set — never to zero, so an unpriced cache column meters at the plain input rate rather than quietly understating cost_usd. A null cost_usd means no row covered the component; the quantity is still captured. Each component records price_id, so a receipt is auditable to the price applied.

  • The cache components are components in their own right, not slices of input_tokens. The input_tokens component is uncached input only, so pricing all three double-counts nothing; the reconstructed input_tokens field on receipt and aggregate totals is the full prompt count (input_tokens + cached_tokens + cache_write_tokens components). Price the components; read the fields.
  • Price the write, not just the read. Until a cache_write_tokens row exists, a write bills at the uncached input rate — which understates it on every provider that charges a premium for one. Add the row before turning caching on at scale.
  • A project can refuse an unpriced model. require_priced_model on the project answers 409 MODEL_NOT_PRICED before the provider is called, so nothing is metered at cost_usd: null in the first place. Off by default.
  • Pricing is not retroactive. A component metered before any row priced it stays cost_usd: null; there is no backfill. The first-price exception lets the first row be dated now or earlier, so write it before the traffic. Quantities survive, so an unpriced window can still be costed outside the platform.
  • Embeddings are the one exception. No tier prices an embedding call: it carries no provider record and its rate is deployment configuration (EMBEDDING_INPUT_1M_TOKEN_PRICE_USD), so a price book row naming the embedding model is ignored. See Pricing embeddings.

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

Past-effective prices are immutable; corrections ship as future-dated rows. A first price is the exception: when nothing prices a (provider, model, component) in the scope being written or any broader one it resolves through, effective_from may be now or earlier (a forced future date would charge null until it lands). Prices can also be declared in a formation with the project_price resource type, keyed on (provider, model, component, effective_from), where effective_from is optional and defaults to deploy time. See Formations Types → Project Price.

Each PUT takes a batch and stops at the first refused row. Both refusals, an unparseable effective_from and a now-or-past-dated write onto an already priced (provider, model, component), return 400 VALIDATION_FAILED with the failing row in error.meta as provider, model, component and effective_from.

Resource prices​

A project prices a tool or agent it owns with resource rows: rows naming its SRN in resource instead of a provider and model. A tool's rows carry meter_type: tool_execution and add a component to every tool_execution event of the tool, beside its unpriced tool_call. An agent's carry meter_type: llm_tokens and add a component to every llm_tokens event of the agent, one per metered turn segment, beside the token components its provider's rows price. A row adds a component and never replaces one; a resource may carry several, one per component.

{ "meter_type": "tool_execution", "resource": "srn:proj_P:tool:tool_ocr",
"component": "page", "unit": "count",
"quantity": { "var": "response.page_count" }, "unit_price": 0.002,
"effective_from": "2026-10-01T00:00:00Z" }

quantity is JSON Logic over the call or turn, and absent it prices one per event. A tool call reads:

KeyWhat it is
inputThe arguments sent, presets merged
actionThe mcp or builtin action called — on a deferred mcp server, the tool its call targets; null for http
responseThe tool's answer after its output_mapping; null unless outcome is ok
outcomeok, error or timeout
duration_msHow long the call took

An agent turn segment reads:

{ "meter_type": "llm_tokens", "resource": "srn:proj_P:agent:agent_triage",
"component": "image_turn", "unit": "count",
"quantity": { "if": [{ "in": ["image", { "var": "response.usage.input_modalities" }] }, 1, 0] },
"unit_price": 0.01, "effective_from": "2026-10-01T00:00:00Z" }
KeyWhat it is
response.usageinput_tokens, output_tokens, cached_tokens, cache_write_tokens, reasoning_tokens, and input_modalities: the sorted part types of the input (text, image, audio, file)
response.cost_usdWhat the provider's rows priced the segment at; null when unpriced
response.stepsSteps the segment ran
response.tool_callsTool calls those steps made
response.stop_reasonThe segment's stop reason
outcomeok or error

Secrets, resolved headers, tool_context and message content are never in it. A result that is not a finite number >= 0 records the component with quantity 0 and cost_usd: null, and writes a usage_quantity_invalid activity entry in the project the event is metered in; the call or turn itself succeeds.

  • Who writes them. A project writes rows for its own tools and agents through PUT /api/v1/projects/{project_id}/prices; a row naming another project's resource is 403 FORBIDDEN, and a row whose meter_type is not its resource's is 400. Admins write global ones through PUT /api/v1/usage/prices. A client tool (run by the caller) has no execution of its own and is 400.
  • The price book's rules apply unchanged, keyed on (resource, component): a now-dated row on a priced key is 400, corrections are future-dated, a component is removed with a future-dated unit_price: 0, the first row may be dated now or earlier, and the row in effect is resolved per call, at metering time.
  • The owner's rows price every call. A call or turn through a share is metered in the calling project but priced from the owner's rows, then the global ones; the calling project's rows never apply, so a caller cannot underprice the owner's resource. A shared agent's turn carries the owner's provider cost plus the owner's resource components.

One shape at every altitude​

Token counts are reported in one schema, UsageTotals, wherever they appear — so a client sums a step, a turn and a month with one type:

AltitudeWhereNotes
StepGET /generations/{id}/transcript → steps[].usagecost_usd is always null: the ledger prices one event per turn segment, and a per-step price would disagree with it after any price change. Null for the whole object means the step reported nothing.
GenerationGET /generations/{id} → usageSingle read only; the listing omits it. null until something is metered.
Session / runGET /sessions/{id}, GET /orchestration-runs/{id}Summed across the generations each contains.
WindowGET /usage/aggregate, GET /usage/receiptBuckets and receipt totals.

input_tokens is the full prompt at every altitude, and uncached_input_tokens is what is left after the two cache dimensions — so input_tokens = uncached_input_tokens + cached_tokens + cache_write_tokens, checkable by the caller. Mind the one collision: the input_tokens component on a usage event is the uncached figure, because components are what get priced. Price the components; read the fields.

The single exception is POST /chat/completions, which reports usage in OpenAI's field names (prompt_tokens, completion_tokens, prompt_tokens_details.cached_tokens, …). That endpoint exists so an OpenAI SDK can target SOAT by base URL alone, and compatibility wins there.

Receipts and reconciliation​

GET /api/v1/usage/receipt?generation_id=… returns a billing receipt for a completed generation: one line item per usage event (its id is the event's), a by_meter_type cost split, reconstructed token totals (input_tokens is uncached input + cached), and a grand total. Every component carries its price-book version and frozen cost, so receipts are reproducible and 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 orchestration run, summed across every node; the roll-up is also the usage object on GET /api/v1/orchestration-runs/{orchestration_run_id}.

Every line item carries its node_id, so a run receipt is also the per-node cost breakdown: an agent node's llm_tokens line plus the compute_execution line of every node execution, so a pure node (a transform, a condition) shows its execution cost alone.

  • A retried node's attempts share one node_id: one line per attempt, no attempt number on the event. 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 loop or sub_orchestration node starts child runs whose events are attributed to the child, so the parent's receipt covers its own nodes only: the starting node's execution cost, not what the children spent (merging would mix node ids from two graphs). The run's usage field spans the subtree; usage_own is the run's own nodes; the parent_orchestration_run_id filter lists the children. See Run usage.

Aggregation​

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

group_by is optional. Omitted, it echoes back null, groups is an empty page, and totals describes the whole window. A value naming no dimension is a 400.

Narrowing a rollup​

Sixteen filters narrow the rollup before bucketing. They intersect and apply to the whole rollup (every bucket, totals, totals.distinct), so each composes with any group_by: session_id with group_by=day is one conversation's spend per day, with group_by=model the same spend by model.

FilterSelects
session_id, actor_idOne conversation, or one end user across every session (End-user attribution)
agent_id, ai_provider_idOne agent's traffic, a shared one included; the spend billed against one provider record
orchestration_run_id, orchestration_idOne run; every run of one orchestration
generation_id, trace_idOne generation's events; everything recorded under one trace
meter_type, model, sourceOne meter, one as-billed SKU, one workload source
trigger_id, action_idThe spend one trigger initiated; one caller-supplied action label
tool_id, outcomeOne tool's executions, a shared one included; the ones that ended ok, error or timeout
publisher_project_idThe calls made through a share of one publisher's resources

The ten naming a resource match the id the event recorded, so they still select the spend of a resource since deleted (Attribution outlives deletion); an id no event recorded empties the rollup rather than dropping the filter, so a mistyped id never reads back as the project's whole spend. orchestration_id is the exception: the event records no orchestration, so a deleted orchestration empties the rollup. The six carrying a value are matched as the event recorded them, so an unrecognised meter, model, source or outcome selects no events; trigger_id and action_id are values because the event stores them denormalized and the spend outlives the trigger.

orchestration_id selects the runs that orchestration started itself, never the subtree a loop or sub_orchestration node started (metered against the child orchestration), so summed across a project's orchestrations it reaches the project total exactly once. For one invocation's subtree, read usage on the run (Run usage).

Every narrowing is echoed under filters on the response, null when unset, so a rollup of zeros is distinguishable from a project that spent nothing. The same filters (plus limit/offset) narrow the raw event listing at GET /api/v1/usage/events.

An unrecognised query parameter is a 400, not ignored (?group_by=day&model=… once returned the project-wide total as if it were one model's). The accepted names are in the error message.

The rollup is grouped and summed in SQL with one join for the chosen dimension, so request cost tracks the buckets answered, not the events behind them.

Counting entities​

groups.total counts buckets, not entities. The null-keyed bucket is real: a project whose traffic is direct agent generations, eval items and trigger firings has exactly one group_by=orchestration_run bucket whether the window held ten events or a million. actor, session, agent, source and tool carry the same null bucket.

To count entities, send include=distinct and read totals.distinct, one COUNT(DISTINCT …) per attribution column:

GET /api/v1/usage/aggregate?project_id=…&group_by=day&limit=1&include=distinct
{
"totals": {
"cost_usd": 12.34,
"event_count": 812004,
"distinct": {
"generations": 811990,
"traces": 811990,
"orchestration_runs": 12,
"agents": 4,
"actors": 318,
"sessions": 902,
"ai_providers": 2,
"tools": 0,
"documents": 37,
"memory_stores": 3,
"publisher_projects": 0
}
}
}

Nulls are not counted: a generation-less completion (chat, memory_extraction, memory_consolidation, eval_judge) moves event_count and nothing in distinct; a standalone generation counts under generations, not orchestration_runs; a retried orchestration node counts once as a run. An agent's knowledge retrieval is metered under its generation, so a turn that retrieved and then failed before the model reported usage still counts under generations.

  • Opt-in because it costs: each key sorts the window once more. Count entities with one request at limit=1&include=distinct; page for spend with no include.
  • On totals only; groups never carry distinct (a COUNT(DISTINCT) per bucket).
  • Deletion does not lower a count: each key counts the entity's id as the event recorded it, so deleting an agent, its generations and traces, an orchestration and its runs, an actor, a session, a provider, a tool, a document or a memory store leaves every figure unchanged.
  • Figures describe one window and do not add: adjacent windows' distinct.sessions overlap where a session spans the boundary, and a run straddling midnight is in two day windows. A wider figure is a wider query.

Any include value other than distinct is a 400.

Paging the groups​

groups is the standard paginated envelope (data, total, limit, offset), where total is the bucket count described above.

  • totals and groups.total always describe the whole [from, to] window, never the page.
  • Groups are ordered by cost_usd descending, ties by key then ai_provider_id ascending, nulls last: the first page is the biggest spenders, and paging never repeats or skips a bucket, even in a window nothing has priced.

limit defaults to 50 and is clamped to 100, as everywhere in the API.

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

For platform meter types group_by=model mixes model ids with SKUs; add meter_type=llm_tokens (or another meter type) to narrow. The filter is echoed as filters.meter_type; an unrecognized value yields an empty rollup, not an error.

Under group_by=model every group also carries ai_provider_id, the AI provider that served it (null on every other dimension), because one project can hold two providers serving byte-identical model names: the dimension buckets on model id and provider, so one name served by two providers is two groups with the same key and different ai_provider_id. Groups still sum to totals.

Spend guards​

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

  • Windowed, per project or agent — runtime.projects.cost_usd.<window>, runtime.projects.tokens.<window>, runtime.agents.cost_usd.<window> over 1h / 24h / 7d / 30d.
  • Tool calls — runtime.projects.tool_calls.<window>, runtime.tools.tool_calls.<window> (and total), runtime.guardrails.tool_calls.<window>, plus errors for the failed ones.
  • Per run, cumulative — runtime.orchestrations.tokens.total, cost_usd.total and tool_calls.total; see per-run spend ceilings.

All read live at evaluation time and fail closed; the full grammar is in Guards and Guardrail Context. Unlike thresholds, which alert, a guard aborts the call.

Thresholds and alerts​

After each usage-event write, every UsageThreshold on the project is evaluated against its windowed aggregate; usage_thresholds.crossed webhook fires for any that cross. Re-fire hysteresis:

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

The webhook payload (data) is the UsageThreshold record as GET /api/v1/usage/thresholds returns it after the fire, plus observed_value, the windowed value that crossed it:

{
"id": "uthr_V1StGXR8Z5jdHi6B",
"project_id": "proj_V1StGXR8Z5jdHi6B",
"metric": "cost_usd",
"window": "calendar_month",
"threshold": 100,
"last_fired_at": "2026-07-14T09:30:00.000Z",
"fired_window_key": "2026-07",
"created_at": "2026-07-01T00:00:00.000Z",
"observed_value": 101.37
}

fired_window_key is null for rolling_24h. Subscribe a webhook to usage_thresholds.crossed (or usage_thresholds.*) to receive it.

Who may act on a usage threshold​

A threshold is this module's only addressable resource, and deleting one is authorized against that threshold's SRN, srn:<project_id>:usage:<threshold_id>, not against the project:

{
"statement": [
{
"effect": "Allow",
"action": ["usage:ManageThresholds"],
"resource": ["srn:proj_V1StGXR8Z5jdHi6B:usage:uthr_V1StGXR8Z5jdHi6B"]
}
]
}

Everything else here — the aggregates, the event feed, the receipts — is a question about a project, so it stays project-scoped, as does creating a threshold. A refusal on a threshold it may not delete is 403; a credential scoped to another project is 403 API_KEY_PROJECT_SCOPE.

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-events --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-aggregate \
--project-id proj_V1StGXR8Z5jdHi6B \
--group-by meter_type \
--from 2026-07-01T00:00:00Z \
--to 2026-08-01T00:00:00Z

Count the runs in a billing cycle without listing them: totals.distinct is the answer (groups.total counts buckets, not runs), and limit=1 keeps the response small:

soat get-usage-aggregate \
--project-id proj_V1StGXR8Z5jdHi6B \
--group-by orchestration_run \
--from 2026-07-01T00:00:00Z \
--to 2026-08-01T00:00:00Z \
--include distinct \
--limit 1

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