Skip to main content

Chatticus

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:

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.

  1. Deploy so the tags exist on billed resources. A new key can take about a day to show up as activatable.
  2. In the Billing console (Cost allocation tags), or with aws ce update-cost-allocation-tags-status, activate chatticus:tenant and chatticus:environment at minimum, and chatticus:application, chatticus:installation and chatticus:component for reporting. In an AWS Organization only the management account can do this.
  3. 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.

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:

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.

This is an approximation of the organization's computer, and it says so:

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:

  1. Application inference profiles, which can carry tags. Confirm they support cost allocation before building on it.
  2. 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

Not in scope