Budgets (v1)
Every Chatticus deployment ships with its own budget tracking, alerts, notifications, and configurable limits. This is how spend is metered without double counting, and what a deployment must carry to be self-contained.
The invariant
Tokens are always counted. Dollars are counted once, by whoever issues the invoice.
Two meters. Every cost belongs to exactly one, and the meter is chosen by billing path, never by category.
| Meter | Covers | Mechanism | Separated per deployment by |
|---|---|---|---|
| AWS | Everything on the AWS invoice, model inference included | AWS Budgets and Cost Explorer | The AWS account |
| Vendor | Model spend AWS cannot see | Our ledger in DynamoDB | A per-deployment vendor project |
"How much are we spending on AI" is a report, not a meter. It is assembled from both. Making it a meter is what double counts anything billed through AWS.
Why Bedrock is the whole problem
Bedrock is model inference on the AWS invoice. It is the only case where the two axes cross, and exactly where a naive design counts twice: the token meter records it because it is a model call, and AWS Budgets records it because Amazon billed for it.
The rule follows from the invariant. Bedrock belongs to the AWS meter.
The ledger still writes a row for a Bedrock turn, because tokens are
always counted and we need them for rate limiting and apportionment. That
row carries billed_via: "aws" and cost_usd: null.
One nullable field and one enum keep the books straight, and both must exist before Bedrock lands. Adding them afterwards means reconciling a period where the answer was wrong and nobody could tell.
One account per deployment
Each deployment gets its own AWS account. The account boundary does the heavy lifting that tags would otherwise do badly: a client's AWS spend is that account's spend, full stop. No apportionment, no tag hygiene standing between us and a correct number, and no risk that one client's runaway cost hides inside another's baseline.
Staging and production are the exception to the one-account-per-environment
picture: they share the production account. That account has exactly one
ChatticusBudgets stack and one monthly limit, which is the combined limit of
staging and production together; the limit and alerts do not split by
environment. Per-environment spend is read from the chatticus:environment
cost tag, which every stack of the two environments carries. The budget stack
already existed in the production account before staging was added, so it keeps
its production tag, and only development and production deploy it.
That boundary covers the AWS meter completely. It does nothing for the vendor meter. OpenAI does not know what an AWS account is. Vendor spend is separated only by giving each deployment its own vendor project and key, and a deployment that shares a key with another one has vendor spend that cannot be attributed to either.
infra/lib/thin-turn-stack.ts reads the OpenAI key from
/chatticus/{environment}/thin-turn/openai-api-key, a per-deployment
parameter the human seeds before live turns. Each deployment gets its own
OpenAI vendor project and key so vendor spend is attributable.
Vendor model prices
The ledger prices each model call from a versioned VendorPriceBook. Both
production compositions (the turn executor and the front door, whose voice understanding
and model gateway observations are priced from it too) use
defaultVendorPriceBook() from conversation/src/ledger/vendor-prices.ts,
a table of OpenAI list prices in USD per million tokens. The book's id is
openai@2026-10-13+pi-ai-1.0.2, and every ledger row records the id of the
book that priced it (price_book_id).
| Model | Input | Cache read | Cache write | Output |
|---|---|---|---|---|
gpt-6-luna (bot turns, low reasoning) |
0.10 | 0.01 | 0.125 | 0.50 |
gpt-6-luna, context above 272000 tokens |
0.20 | 0.02 | 0.25 | 0.75 |
gpt-5-nano (voice understanding) |
0.05 | 0.005 | 0 | 0.40 |
gpt-5-mini |
0.25 | 0.025 | 0 | 2.00 |
The figures are copied from the openai catalog shipped inside
@earendil-works/pi-ai 1.0.2 (dist/providers/data/openai.json, read
2026-10-13), the catalog the Pi harness prices with. They replace the earlier
LiteLLM figures (the base input and output rates are the same). They have not
been compared with OpenAI's own price page; a vitest guard pins the table to
the pi-ai catalog, so an upgrade that changes a figure fails until the table
and the book id are updated.
A call is priced by its own model: uncached input at the input rate, cache
reads and writes at their cache rates, output (reasoning included) at the
output rate, all at the tier the call's context size (uncached input plus
cache reads plus cache writes) selects. A turn that switches model prices each
call at its own model. A model whose source lists no cache rate would price its
cached tokens at the input rate and the row's priced_with would carry
uncached_fallback. Service tiers (flex, priority) are not modelled; Chatticus
requests none.
A model with no entry is never priced as zero. A turn on such a model fails
before the model is called, with the reason "No price is tabled for model ...".
A spend record of such a model (for example a voice understanding call) keeps
its tokens, carries unpriced_calls greater than zero and has no cost_usd,
which means unknown. A typical turn (about 4,700 input and 220 output tokens on
gpt-6-luna) costs about $0.00058 when none of the input is cached.
Where the ledger row of a turn comes from
A turn's ledger row is not counted; it is a projection of the turn's cost records. Each model call of a
turn is one keyed record (chatticus.cost, key entry-<entryId>) in the durable Pi session of the bot
and channel, derived from the assistant entry that answered the call and priced from the book above (see
docs/COST_ACCOUNTING.md, section 3). The owner derives the records and then writes the turn's ledger
row, and its turn index item, from the sum of those records, as absolute totals rather than additions.
It does this when a turn concludes or fails, when it is handed on (yield) or parked on the computer, and
when an owner resumes a turn. The same calls therefore give the same row from a Lambda owner or from the
computer owner, a retry or takeover writes the same figures again, and the model gateway writes nothing
to the ledger; it only logs the usage it saw. The old per-turn counters
(ledger_input_recorded, ledger_output_recorded) no longer exist.
The ledger row keeps its shape (input_tokens counts cached tokens too, cost_usd is the sum of the
calls' own dollars, price_book_id, priced_with, models, unpriced_calls, plus record_count, the
number of call records it was written from; a write with fewer records than the row already holds is
refused). The row at {tenant}#cost#{yyyy-mm} / turn#{turnId} is the turn index item: the same totals
with bot_id, channel_id, prompt_author_id, prompt_message_seq, message_seq, owner_kind and a
per-model breakdown. Until the rollup moves to the turn index (Kanbus chatticus-0d265c, T7) the daily
rollup still sums the ledger rows, so budget alerts and the computer-work meter are unchanged.
Every other model call, and calls the session never saw
Three more kinds of spend reach the ledger, all as projections of cost records (docs/COST_ACCOUNTING.md, section 3.9), never as
additions:
- Compaction. The summarization Pi runs to shrink a long conversation is a model call with no assistant entry. Its tokens are
recovered from the session's own usage ledger and recorded as
compactioncost records, so they are in the turn's row and totals. - Voice understanding. The call that repairs a spoken line is a
voice_understandingcost record, priced from the same book (cache reads included) and projected to the ledger rowvoice:<uuid>and a cost index itemvoice#<uuid>that names the member and the channel and no bot. If the record cannot be written the front door logsvoice_cost_not_recordedand the line still goes through. - Calls only the gateway saw. The model gateway keeps an observation of each call a container made (
{tenant}#cost#observed, 35 days). The daily rollup reconciles them first: a call whose response id is on the turn's session row is the same call and adds nothing; a call nothing in the session accounts for after two hours (the vendor answered, the session never committed) becomes a ledger rowturn#gateway:<id>withsource: gateway, markedestimatedwhen the stream was interrupted. A gateway row whose call a session row lists is skipped in the sum, so no call is counted twice.
What the rollup does with calls it cannot price
A ledger row with unpriced calls has no cost_usd (unknown, never zero). The daily rollup no longer drops such rows silently: it
adds their unpriced_calls to the rollup row (unpriced_calls, beside vendor_cost_usd, whose dollars leave those calls out),
logs vendor_cost_unpriced_calls for the day, and carries the month-to-date count in the threshold alert payload
(unpriced_calls). Read vendor_cost_usd as "known dollars" whenever unpriced_calls is positive.
Dollars in the ledger feed the daily rollup's vendor_cost_usd and
combined_report_usd, and so the month-to-date meter that the per-organization
computer-work ceiling reads.
Configurable limits
Limits are deployment configuration, not constants. A ChatticusBudgets
construct lives in a dedicated ChatticusBudgets CDK stack (not
ChatticusSnapshots). Deploy it with infra/deploy-chatticus-budgets.sh
after setting both CHATTICUS_BUDGETS_* env vars. The construct takes a
monthly limit, alert thresholds, and notification targets, with defaults
that suit a small deployment and an override per environment.
Cutover: AWS budget names are unique per account
(chatticus-monthly-aws). An account that already has the budget on
ChatticusSnapshots must deploy snapshots first (which deletes the old
CFN-managed budget), then immediately deploy ChatticusBudgets to
recreate the same name. See infra/README.md.
A new account has no history to derive a limit from, and that is a feature. Start low enough that any spend is signal and raise it deliberately, rather than starting high and learning what normal was after an invoice. Historical spend from a previously shared account is not a baseline for a fresh one and should not be used as one.
Cost allocation tags
With one account per deployment, tags stop being what separates clients and become what breaks a single client's spend down internally.
| Tag | Values | On |
|---|---|---|
chatticus:application |
Chatticus |
Every stack and every computer task |
chatticus:environment |
development, staging, production; shared for stacks that are not per-environment |
Every stack and every computer task |
chatticus:installation |
The installation's readable name, for example Anthus AI Solutions |
Every stack and every computer task, when configured |
chatticus:component |
thin-turn, web, auth, computer, snapshots, dns, budgets, deploy, integration-test | Every stack (a computer task is computer) |
chatticus:tenant |
An organization's tenant_id |
Computer tasks, at run time |
The installation is one deployment of Chatticus, named by the operator
(CHATTICUS_INSTALLATION_NAME, a GitHub Actions variable in CI). It is not an
organization: one installation serves many organizations, and only computer
tasks carry an organization, in chatticus:tenant. lib/tagging.ts holds the
stack registry and refuses to synthesize a stack it has not classified, so a
new stack cannot ship untagged. The installation name is optional; deploying
without it warns and removes the tag from an already-tagged stack, so keep
the variable set for every deploy path, including local scripts.
The tenant tag on a computer task is for reporting in a management account. The spend ceiling does not depend on it: see "Platform-hosted organizations: priced from recorded runtime". Shared infrastructure is reported as overhead, not apportioned by guesswork.
Activating the tags (person-step)
A cost allocation tag does not appear in Cost Explorer until it is activated, and activation is not retroactive, so it belongs in the deployment runbook rather than in someone's memory.
- Deploy so the tags exist on billed resources. A new key can take about a day to show up as activatable.
- In the Billing console (Cost allocation tags), or with
aws ce update-cost-allocation-tags-status, activatechatticus:tenantandchatticus:environmentat minimum, andchatticus:application,chatticus:installationandchatticus:componentfor reporting. In an AWS Organization only the management account can do this. - The rollup no longer reads these tags for any organization, so activation is optional and serves reporting only.
ChatticusSnapshots and ChatticusComputers are deliberately not
per-environment, so they carry chatticus:environment=shared.
Where a turn's tokens and cost are written
Every request's token counts and cost must be recorded somewhere we can find, aggregate, and alarm on. Neither Bedrock nor any external API will hand us a per-organization breakdown, so this is ours to keep regardless of vendor.
Both, with different jobs.
| DynamoDB row | Structured log line | |
|---|---|---|
| Job | System of record | Observability |
| Read by | The daily rollup, billing, per-organization attribution | A human asking why Tuesday spiked |
| Lifetime | As long as the account exists | The log group's retention |
| Authoritative | Yes | No |
The log line is derived from the row, so the two cannot disagree. The rule to hold: DynamoDB is what you bill from, logs are what you debug from.
Not every row is a turn. Model calls made outside a turn, such as the voice
understand-the-user step (docs/VOICE.md), are recorded under their own id
with a voice: prefix in the row's turn_id field. Rollups sum every row
per organization; anything that joins ledger rows to turns must skip those
ids.
Logging this is effectively free
The cost worry is misplaced, and the numbers say so plainly. At household scale, on the order of a thousand turns a day and a few hundred bytes a record, the ledger produces roughly twelve megabytes a month. Log ingestion is free to five gigabytes and fifty cents a gigabyte after, storage is three cents a gigabyte-month, and a Logs Insights query is charged on the data it scans. A hundredfold increase in traffic still lands inside the free tier.
The Lambda already writes to CloudWatch. Adding a structured JSON line costs the bytes and nothing else.
The real cost trap is metrics, not logs
CloudWatch treats every unique combination of dimensions as a separate custom metric, at thirty cents per metric per month. A metric filter or an embedded-metric-format record dimensioned by organization and by model does not produce one metric; it produces one per pair. A hundred organizations across three models is three hundred metrics, ninety dollars a month, which for a small deployment exceeds the infrastructure it is measuring.
So: alerts come from the daily rollup reading DynamoDB, not from log-derived metrics. Keep custom metrics to a small fixed set, such as total spend per environment. Never dimension a metric by organization.
Why not logs alone
Tempting, because Logs Insights is good and the ingestion is free. Three reasons it cannot be the only copy.
- Retention is a cliff, not a slope. Insights can only query what is still retained, so a year-over-year question is unanswerable the day after retention expires.
- An invoice needs an authoritative number. In v3 this data decides what an organization is charged. Scanning logs is not a defensible basis for a bill, and a log group is not an audit trail.
- Scanning is O(data) forever. A monthly rollup re-scans the whole period every run, where a DynamoDB query reads one partition.
All Chatticus CDK log groups use 30-day retention (CHATTICUS_LOG_RETENTION
in infra/lib/log-retention.ts), including WebStack BucketDeployment and
development auto-delete custom-resource handlers. DynamoDB is the system of record;
CloudWatch logs are a debug window only, long enough for incident lookback and
short enough to bound storage cost. Retention is set explicitly on every log group
in CDK so nothing relies on the never-expire default.
Record shape
Log the ledger event as JSON, not the key=value style used
elsewhere in the codebase. Insights parses both, but stats sum() over a
JSON field is direct where the other needs a parse expression, and this
is the one log line written specifically to be aggregated later.
Combining the two meters
No always-on aggregator; that reintroduces the idle floor the whole architecture avoids. EventBridge Scheduler already wakes a Lambda for turn deadlines, and a daily budget rollup is the same shape: a routine wake-up, not something in the token path.
Once a day a function reads Cost Explorer for the AWS side and the ledger for the vendor side, writes one rollup row per day per organization and per environment, and publishes to SNS when a threshold is crossed. AWS Budgets fires its own native alerts to the same topic, and the rollup records that an alert fired, so a human sees one sequence rather than two disconnected emails whose relationship they must reconstruct.
A brand-new account reports nothing for roughly a day while Cost Explorer populates. Say so in the runbook, or the first quiet day reads as a broken alarm.
Customer-account organizations: read the whole account
An organization that runs in its own AWS account (cross-account setup) is not
attributed by tags. The account boundary already separates its spend, so the
daily rollup assumes that organization's ChatticusOrganizationComputerRole
and reads the account's total UnblendedCost for the day. The role therefore
needs ce:GetCostAndUsage (in infra/customer-role.yml, and required by
self-setup), and the rollup Lambda needs sts:AssumeRole on that role name.
Two prerequisites sit on the customer side and are person-steps:
- Cost Explorer must be enabled in the customer account. Enabling it for the first time is a console action and the data takes about a day to appear.
- The customer must run the current published template, which carries the permission. A role without it fails self-setup.
A read that fails (assume-role denied, permission missing, Cost Explorer not
enabled) writes the day as ce_status=error with no dollar figure, never as
zero. error and pending both make the meter unavailable, and an
organization with a ceiling and an unavailable meter has new computer work
refused. Days Cost Explorer has not populated stay pending.
The default ceiling
Every organization starts with a monthly ceiling of 50.00 USD, whether it was
created in the app or seeded by the operator. Self-setup replaces it with the
ceiling the owner chooses, and an owner can raise it when work pauses.
Organizations created before this rule hold no ceiling; give them the default
once with
node conversation/bin/migrate-organization-spend-ceiling.ts --environment ENV
(a dry run; add --execute to write, --tenant T to limit it to one
organization). It writes only the ceiling, and only while the organization
still holds none, so it never changes a ceiling an owner set. An enabled
organization whose month already cost 50.00 or more is paused the moment it
gets the ceiling, so read the month-to-date rollup before running --execute.
Platform-hosted organizations: priced from recorded runtime
An organization hosted in the deployment account (setup path anthus-managed,
the default) has no account of its own to read, and Cost Explorer cannot split
the deployment account's bill by organization: that would need chatticus:tenant
as an active cost allocation tag, and only the AWS Organizations management
account can activate one, so a member-account deployment can never read it. The
rollup therefore prices what the control plane already recorded.
- The owner supervisor routes write one durable item per owner run, under
{tenant}#computer_runtime, sort keygeneration#{owner generation}. The bootstrap exchange opens it (started_at), and every accepted poll moveslast_seen_atforward. The item outlives the owner and the computer's owner fields. - The daily rollup sums, for the rollup day, the seconds of each run that fall
inside that UTC day, at 0.009875 USD per hour (0.25 vCPU at 0.03238 plus 0.5 GB
at 0.00356, the Fargate Linux/ARM64 on-demand rates for us-east-1, taken from
the AWS Price List API on 2026-09-11). A run is billed for at least 60 seconds,
as Fargate bills. The constant is
COMPUTER_TASK_USD_PER_HOURinconversation/src/budget/computer-runtime.ts; change it when the rates or the task size ininfra/lib/computer-stack.tschange. - A day the computer never ran is a measured zero with
ce_status=ok. Runtime records that cannot be read writece_status=errorwith no figure. The meter never reads an unmeasured organization as zero: it counts such a day at the day's model spend plus the most the computer can cost in a day (24 hours at the rate above,MOST_A_HOSTED_COMPUTER_COSTS_IN_ONE_DAY_USD), and pauses computer work as "cannot be read" only when that count reaches the ceiling. An unread hosted day is not read again on later runs, because runtime that was never recorded would read as a measured zero. - An organization in its own AWS account has no such bound. A day its account could not be read, or whose data is still loading, makes the meter unknown and pauses computer work for an organization with a ceiling. Each rollup run reads every such earlier day of the month again, so one failed read does not hold the pause for the rest of the month.
- Every organization-day a run could not read is logged as
spend_unreadableand published as onespend_unreadablemessage on the budgets topic.
This is an approximation of the organization's computer, and it says so:
- Snapshot storage (S3), the shared stack costs and data transfer are not in it. Snapshot size is not recorded; at 0.023 USD per GB-month it is cents for a workspace of a few GB.
- The seconds between the task starting and the bootstrap, and between the last accepted poll and the task ending, are not counted, except through the one-minute minimum. An owner that is killed leaves its last poll as its end.
- The meter reads days that have a rollup row. The rollup runs at 06:00 UTC for the previous day, so today's usage reaches the ceiling decision the next morning.
- At the current task size an owner can run at most about 7.35 USD in a 31-day month, so a ceiling above that never trips on this meter alone.
Customer-account organizations are not touched by this meter; they keep the whole-account read above.
Per-organization attribution
Tagged AWS resources attribute cleanly. Vendor spend attributes cleanly, because the ledger is written per turn and a turn belongs to an organization.
Bedrock does not. A Bedrock invocation does not carry our tenant tag, so Cost Explorer can total it and cannot split it. Two ways out, in preference order:
- Application inference profiles, which can carry tags. Confirm they support cost allocation before building on it.
- Apportion the Bedrock total across organizations by the token counts already in the ledger. Approximate, and honest about being so.
Per-organization cost is what billing needs in v3. An attribution gap found then costs far more than one designed around now.
Still open
- Who receives a deployment's budget notifications, and who holds the payment method. Member accounts sit under one AWS Organization, so consolidated billing means Anthus sees every deployment's spend and can set budgets across member accounts from the management account, with Cost Explorer grouping by linked account. Whether the customer also receives their own deployment's alerts is a product decision, not an infrastructure one.
- Whether each deployment gets its own vendor account or a project inside ours. Separate projects are enough for attribution; separate accounts also separate liability and rate limits.
Not in scope
- Charging anyone. The boundary billing needs is built here; the invoice is not.
- Per-user budgets inside a tenant. The deployment is the unit of spend and the tenant is the unit of internal chargeback.
- Reserved capacity, savings plans, or any commitment purchase.