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
- SOAT running locally (Quick Start).
- Ollama running locally with
qwen2.5:0.5b. - Key Concepts if new to SOAT.
- CLI
- SDK
- curl
export SOAT_BASE_URL=http://localhost:5047
Snippets use the SoatClient created in Step 1.
import { SoatClient } from '@soat/sdk';
export SOAT_URL=http://localhost:5047
Step 1 — Log in as admin
Admin is the built-in superuser role (Users).
- CLI
- SDK
- curl
ADMIN_TOKEN=$(soat login-user --username admin --password Admin1234! | jq -r '.token')
export SOAT_TOKEN=$ADMIN_TOKEN
const soat = new SoatClient({ baseUrl: 'http://localhost:5047' });
const { data: login } = await soat.users.loginUser({
body: { username: 'admin', password: 'Admin1234!' },
});
const adminSoat = new SoatClient({
baseUrl: 'http://localhost:5047',
token: login.token,
});
ADMIN_TOKEN=$(curl -s -X POST "$SOAT_URL/api/v1/users/login" \
-H "Content-Type: application/json" \
-d '{"username":"admin","password":"Admin1234!"}' | jq -r '.token')
Step 2 — Create a project
Usage is metered and budgeted per project.
- CLI
- SDK
- curl
PROJECT_ID=$(soat create-project --name "Metering Demo" | jq -r '.id')
echo "PROJECT_ID: $PROJECT_ID"
const { data: project } = await adminSoat.projects.createProject({
body: { name: 'Metering Demo' },
});
const PROJECT_ID = project.id;
PROJECT_ID=$(curl -s -X POST "$SOAT_URL/api/v1/projects" \
-H "Authorization: Bearer $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{"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.
- CLI
- SDK
- curl
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"
const { data: provider } = await adminSoat.aiProviders.createAiProvider({
body: {
project_id: PROJECT_ID,
name: 'Local Ollama',
provider: 'ollama',
default_model: 'qwen2.5:0.5b',
},
});
const { data: agent } = await adminSoat.agents.createAgent({
body: {
project_id: PROJECT_ID,
ai_provider_id: provider.id,
name: 'Metered Agent',
instructions: 'You are a concise assistant. Keep answers under 20 words.',
},
});
const { data: generation } = await adminSoat.agents.createAgentGeneration({
path: { agent_id: agent.id },
query: { wait: true },
body: { messages: [{ role: 'user', content: 'Name three uses for a paperclip.' }] },
});
const GENERATION_ID = generation.id;
AI_PROVIDER_ID=$(curl -s -X POST "$SOAT_URL/api/v1/ai-providers" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d "{\"project_id\":\"$PROJECT_ID\",\"name\":\"Local Ollama\",\"provider\":\"ollama\",\"default_model\":\"qwen2.5:0.5b\"}" | jq -r '.id')
AGENT_ID=$(curl -s -X POST "$SOAT_URL/api/v1/agents" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d "{\"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')
GENERATION_ID=$(curl -s -X POST "$SOAT_URL/api/v1/agents/$AGENT_ID/generate?wait=true" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{"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.
- CLI
- SDK
- curl
soat list-usage-events --generation-id "$GENERATION_ID"
const { data: meters } = await adminSoat.usage.listUsageEvents({
query: { generation_id: GENERATION_ID },
});
console.log(meters.data);
curl -s "$SOAT_URL/api/v1/usage/events?generation_id=$GENERATION_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq
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.
- CLI
- SDK
- curl
soat get-usage-receipt --generation-id "$GENERATION_ID"
const { data: receipt } = await adminSoat.usage.getUsageReceipt({
query: { generation_id: GENERATION_ID },
});
console.log(receipt.input_tokens, receipt.output_tokens);
curl -s "$SOAT_URL/api/v1/usage/receipt?generation_id=$GENERATION_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq
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.
- CLI
- SDK
- curl
soat get-usage-aggregate --project-id "$PROJECT_ID" --group-by day
soat get-usage-aggregate --project-id "$PROJECT_ID" --group-by model
const { data: byDay } = await adminSoat.usage.getUsageAggregate({
query: { project_id: PROJECT_ID, group_by: 'day' },
});
const { data: byModel } = await adminSoat.usage.getUsageAggregate({
query: { project_id: PROJECT_ID, group_by: 'model' },
});
console.log(byDay.totals, byModel.groups.data);
curl -s "$SOAT_URL/api/v1/usage/aggregate?project_id=$PROJECT_ID&group_by=day" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq
curl -s "$SOAT_URL/api/v1/usage/aggregate?project_id=$PROJECT_ID&group_by=model" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq
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.
- CLI
- SDK
- curl
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
await adminSoat.usage.upsertPriceBook({
body: {
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' },
],
},
});
const { data: prices } = await adminSoat.usage.getPriceBook();
console.log(prices.prices);
curl -s -X PUT "$SOAT_URL/api/v1/usage/prices" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d '{"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"}]}'
curl -s "$SOAT_URL/api/v1/usage/prices" -H "Authorization: Bearer $ADMIN_TOKEN" | jq
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.
- CLI
- SDK
- curl
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"
await adminSoat.usage.createUsageThreshold({
body: { project_id: PROJECT_ID, metric: 'tokens', window: 'rolling_24h', threshold: 1 },
});
await adminSoat.usage.createUsageThreshold({
body: { project_id: PROJECT_ID, metric: 'cost_usd', window: 'calendar_month', threshold: 50 },
});
await adminSoat.webhooks.createWebhook({
body: {
project_id: PROJECT_ID,
name: 'budget-alerts',
url: 'https://example.com/usage-alerts',
events: ['usage.threshold_crossed'],
},
});
const { data: thresholds } = await adminSoat.usage.listUsageThresholds({
query: { project_id: PROJECT_ID },
});
console.log(thresholds.data);
curl -s -X POST "$SOAT_URL/api/v1/usage/thresholds" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d "{\"project_id\":\"$PROJECT_ID\",\"metric\":\"tokens\",\"window\":\"rolling_24h\",\"threshold\":1}"
curl -s -X POST "$SOAT_URL/api/v1/usage/thresholds" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d "{\"project_id\":\"$PROJECT_ID\",\"metric\":\"cost_usd\",\"window\":\"calendar_month\",\"threshold\":50}"
curl -s -X POST "$SOAT_URL/api/v1/webhooks" \
-H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \
-d "{\"project_id\":\"$PROJECT_ID\",\"name\":\"budget-alerts\",\"url\":\"https://example.com/usage-alerts\",\"events\":[\"usage.threshold_crossed\"]}"
curl -s "$SOAT_URL/api/v1/usage/thresholds?project_id=$PROJECT_ID" \
-H "Authorization: Bearer $ADMIN_TOKEN" | jq
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_monthfires at most once perYYYY-MMwindow (window_key); re-arms at the month boundary.rolling_24hre-arms once the windowed value drops below 90% of the threshold (window_keyisnull).
Thresholds are immutable: delete and recreate to change one, which resets its fire state.
Usage module: data model, pricing tiers, permissions.