Skip to main content

Meter and Budget Your Project's Spend

Every completed generation writes an append-only usage event with the provider's token counts. This tutorial prices that ledger, aggregates it per project, and subscribes a webhook to the usage.threshold_crossed budget alert.

Prerequisites​

export SOAT_BASE_URL=http://localhost:5047

Step 1 — Log in as admin​

Admin is the built-in superuser role (Users).

ADMIN_TOKEN=$(soat login-user --username admin --password Admin1234! | jq -r '.token')
export SOAT_TOKEN=$ADMIN_TOKEN

Step 2 — Create a project​

Usage is metered and budgeted per project.

PROJECT_ID=$(soat create-project --name "Metering Demo" | jq -r '.id')
echo "PROJECT_ID: $PROJECT_ID"

Step 3 — Create an agent and run a generation​

Create a local Ollama AI provider and an agent, then run one generation.

AI_PROVIDER_ID=$(soat create-ai-provider \
--project-id "$PROJECT_ID" \
--name "Local Ollama" \
--provider "ollama" \
--default-model "qwen2.5:0.5b" | jq -r '.id')
echo "AI_PROVIDER_ID: $AI_PROVIDER_ID"
AGENT_ID=$(soat create-agent \
--project-id "$PROJECT_ID" \
--ai-provider-id "$AI_PROVIDER_ID" \
--name "Metered Agent" \
--instructions "You are a concise assistant. Keep answers under 20 words." | jq -r '.id')
echo "AGENT_ID: $AGENT_ID"
GENERATION_ID=$(soat create-agent-generation --wait true \
--agent-id "$AGENT_ID" \
--messages '[{"role":"user","content":"Name three uses for a paperclip."}]' | jq -r '.id')
echo "GENERATION_ID: $GENERATION_ID"

Step 4 — Inspect the raw usage meter​

Each completed generation records one usage event whose components carry input_tokens, output_tokens, cached_tokens, cache_write_tokens and a non-billable reasoning_tokens detail. cost_usd is null until prices are registered; SOAT ships none by default.

soat list-usage-events --generation-id "$GENERATION_ID"

The meter is written shortly after the response returns; if the list is empty, re-run.


Step 5 — Read the generation receipt​

A receipt rolls a generation's events into per-model line items with a by_meter_type split and token totals, for reconciling against a provider invoice. Pass orchestration_run_id instead of generation_id for the same shape summed across an orchestration run.

soat get-usage-receipt --generation-id "$GENERATION_ID"

Step 6 — Aggregate the project's usage​

get-usage-aggregate (Aggregation) rolls the project up over an optional [from, to] window, bucketed by one dimension (model, ai_provider, agent, orchestration_run, day, meter_type, actor, session, source) or by none for totals alone.

soat get-usage-aggregate --project-id "$PROJECT_ID" --group-by day
soat get-usage-aggregate --project-id "$PROJECT_ID" --group-by model

Each group and totals carry summed token counts and cost_usd (null until Step 7). Under group_by=model each group also carries ai_provider_id: two providers can serve the same model name, so the model dimension buckets on both.


Step 7 — Register prices (they apply going forward)​

Cost is computed at write time from the price in effect. Prices are immutable and non-retroactive: effective_from must be in the future, and already-metered usage stays at null.

soat upsert-price-book \
--prices '[{"provider":"ollama","model":"qwen2.5:0.5b","component":"input_tokens","unit":"token","unit_price":0.0000001,"effective_from":"2099-01-01T00:00:00.000Z"},{"provider":"ollama","model":"qwen2.5:0.5b","component":"output_tokens","unit":"token","unit_price":0.0000002,"effective_from":"2099-01-01T00:00:00.000Z"}]'
soat get-price-book

Resolution is most-specific-first: per-provider override, then project + provider-slug rate, then this global default (Usage → Pricing).


Step 8 — Set a budget threshold and subscribe to the alert​

A UsageThreshold fires the usage.threshold_crossed webhook after any usage-event write that crosses a project's windowed metric. A tokens threshold works immediately (tokens are always metered); a cost_usd threshold counts priced usage only. Create both, then subscribe a webhook.

soat create-usage-threshold \
--project-id "$PROJECT_ID" \
--metric tokens \
--window rolling_24h \
--threshold 1
soat create-usage-threshold \
--project-id "$PROJECT_ID" \
--metric cost_usd \
--window calendar_month \
--threshold 50
soat create-webhook \
--project-id "$PROJECT_ID" \
--name "budget-alerts" \
--url "https://example.com/usage-alerts" \
--events '["usage.threshold_crossed"]'
soat list-usage-thresholds --project-id "$PROJECT_ID"

The tokens/rolling_24h threshold (limit 1) is already exceeded by Step 3's generation, so the next metered generation fires the webhook. Delivery uses the standard signed envelope with this data:

{
"threshold_id": "uthr_V1StGXR8Z5jdHi6B",
"project_id": "proj_V1StGXR8Z5jdHi6B",
"metric": "tokens",
"window": "rolling_24h",
"window_key": null,
"threshold": 1,
"observed_value": 62
}

Re-fire hysteresis:

  • calendar_month fires at most once per YYYY-MM window (window_key); re-arms at the month boundary.
  • rolling_24h re-arms once the windowed value drops below 90% of the threshold (window_key is null).

Thresholds are immutable: delete and recreate to change one, which resets its fire state.


Usage module: data model, pricing tiers, permissions.