---
title: Every AI Call Gets a Receipt
date: '2026-10-21'
description: 'One AI bill a month, twenty kinds of model call. How Sumalos prices every attempt as it happens and caps spending per person and per company.'
author: Pablo Fernandez
tags:
  - AI
  - Agents
  - Cost
  - Observability
  - Redis
  - Postgres
source: 'https://www.pablofr.com/blog/ai-cost-per-feature'
---
Someone sends Suma, the assistant in [Sumalos](/blog/pixel-art-ai-assistant), one message: "invoice Talleres García for the brake job." Before the invoice preview appears, the reply model has run three or four steps, a search tool has embedded the question, and an input guard has classified it. After the reply, a worker asks a decision model whether the message held anything worth remembering, maybe extracts a fact and embeds that too. One sentence, somewhere between five and ten model calls, across four or five models.

At the end of the month all of that arrives as one line on one invoice from the AI provider. The bill knows the total. It doesn't know that the memory gate is a rounding error, that one organization's document reading costs more than everybody's chat, or that a retry storm on Tuesday doubled the cost of every reply for an hour.

So the question for this post: how do you know what each feature, each customer and each message costs, and stop spending at a limit, without trusting an estimate made in a spreadsheet? My answer is boring on purpose. Every attempt at every model call writes one priced row, and everything else (dashboards, receipts, caps) is a query over those rows.

## One door for every model

A ledger that misses calls is worse than none, because it looks complete. So the first rule in Sumalos is that nothing outside one module may name a model. Code asks for a feature, with who it is for:

```typescript:packages/server/src/ai/models.ts
// The one place models come from. Ask for a feature, with who it is for:
//
//   const model = aiModel("chat_title", { organizationId, userId, channel: "web", requestId });
//   await generateText({ model, maxRetries: 0, prompt });
```

A feature is a row in a registry, `AI_FEATURES`, and there are twenty of them today: the web and WhatsApp replies, titles, compaction, voice notes, embeddings, the memory gate and extractor, five document-reading features, scheduled tasks, two guards, web search and a couple more. Each one declares its model, retries, timeout, who pays for it, and whether it sends personal data:

```typescript:packages/server/src/ai/features.ts
chat_title: {
  label: "Títulos de conversaciones",
  description: "El nombre corto de cada conversación, a partir del primer mensaje.",
  kind: "language",
  // A few words: the cheapest current OpenAI model is plenty (0,10 $ / 0,50 $ per million).
  model: "openai/gpt-6-luna",
  retries: 1,
  timeoutMs: 20_000,
  budgetClass: "interactive",
  userBillable: true,
  enabled: true,
  personalData: true,
},
```

The defaults live in code; the environment can override them, and staff can override both from an admin page without a deploy. That makes swapping a model a data change, which matters later.

The rule is enforced by a test, `no-direct-models.test.ts`, which walks every source file in the repo and fails on an import of a provider package, a string that looks like a model id (`"openai/..."`), or a call to `generateText`, `streamText`, `embed`, `transcribe` or `experimental_decide` whose `model` didn't come from the factory. While the older code moved over, it had a `PENDING` list of files allowed to break the rule, and the test also fails if a listed file already complies. That list can only shrink. Today it is empty.

## A row per attempt, not per call

The factory hands back the model wrapped in a meter. For language models it is an [AI SDK middleware](https://ai-sdk.dev/docs/ai-sdk-core/middleware); transcription and decision models have no middleware hook, so they get a wrapper object with the same interface. Every call goes through the same four stages:

```
  generateText / streamText / embed / transcribe / decide
     │
     ▼
  1. feature off? hard limit reached? ──▶ row: blocked_by_budget, throw
     │
     ▼
  2. attempt 1, 2, 3 ...  ──────────────▶ one row each, linked by retryOf
     │
     ▼
  3. price: gateway's cost, else table ─▶ cost_nano_usd + cost_source
     │
     ▼
  ai_usage_events ──▶ Redis counters ──▶ warning emails, once per period
```

*The meter around every model call: refuse early, record each attempt, price it, then charge the counters.*

The second stage is the one people usually skip. SDKs retry on their own, and a call that took three attempts shows up in your code as one call that was slow. In Sumalos the meter does the retrying itself, with the feature's own count, timeout and exponential backoff, and writes a row for every attempt. After the last one, the error is marked as not retryable, so the SDK's own `maxRetries` can't multiply attempts behind the meter's back:

```typescript:packages/server/src/ai/usage/middleware.ts
const info = classifyError(error);
const willRetry = info.retryable && attempt <= feature.retries && !isAbortError(raw);
void recordAiEvent(
  finalize(
    {
      ...d,
      latencyMs: Date.now() - started,
      willRetry,
      errorClass: info.errorClass,
      errorCode: info.errorCode,
      httpStatus: info.httpStatus,
      errorMessage: info.message,
    },
    "error",
  ),
);
if (!willRetry) throw markFinal(error);
retryOf = d.id;
```

A failed attempt can still cost money (a stream that died halfway was billed for what it produced), so it belongs in the ledger next to the one that worked. A spike in `willRetry` rows is also the first sign of a provider having a bad hour, well before anyone complains about slow replies.

Writing the row never sits in the caller's way. `recordAiEvent` does the insert, the counter update and the warnings after the fact, logs failures instead of throwing them, and each process waits for the writes in flight when it shuts down.

## Whose number is the price

The second question is what a row should say it cost. Sumalos reaches every model through the [Vercel AI Gateway](https://vercel.com/docs/ai-gateway), which reports the cost of each call in its response metadata, as a decimal string in dollars. That is what the provider will actually charge, so it wins:

```typescript:packages/server/src/ai/usage/cost.ts
/** The cost to record: the gateway's when it said, the price table's otherwise. */
export function costOf(
  kind: AiModelKind,
  usage: MeteredUsage,
  providerMetadata: unknown,
  price: AiModelPrice | null,
  { estimated = false }: { estimated?: boolean } = {},
): { costNanoUsd: number; costSource: AiCostSource } {
  const fromGateway = estimated ? null : gatewayCostNano(providerMetadata);
  if (fromGateway !== null) return { costNanoUsd: fromGateway, costSource: "gateway" };
  const fromTable = priceTableCostNano(kind, usage, price);
  if (fromTable !== null) return { costNanoUsd: fromTable, costSource: estimated ? "estimated" : "price_table" };
  return { costNanoUsd: 0, costSource: "none" };
}
```

When the gateway doesn't say, a price table takes over: dollars per million tokens of input, output, cached input, cache writes, reasoning and embeddings, per minute of audio and per decision call, each row with an `effective_from` so a call is priced at the rate in force when it was made. A worker seeds the table once a day from the gateway's model list. And when neither knows, the row says `none` with a cost of zero, and the dashboard shows it, instead of a guess that looks like a fact.

Every row keeps where its number came from. That one column, `cost_source`, is what lets me trust a total: I can see what share of it is the provider's own figure.

Money is stored as an integer count of nano-dollars, a billionth of a dollar. That sounds excessive until you price an embedding: six tokens on `text-embedding-3-small` cost $0.00000012, which is 120 nano-dollars and far below a micro-dollar. Floats would round these into noise, and cents would round them to zero. Euros are only for display and for setting limits, converted at a configured rate.

I checked the arithmetic against real calls through the gateway on 19 October 2026. A title with 28 input tokens and 90 output tokens on the model it used then cost $0.00009048 according to the gateway, and the ledger stored 90,480 nano-dollars, the same figure the price table gives for that model (28 × $0.435 plus 90 × $0.87 per million). The embedding in the same request was the 120 nano-dollars above, and the request's total came to 90,600.

### The stream nobody finished reading

Streams have one awkward case. If the person closes the chat mid-answer, the stream is cut before the provider sends its usage and its cost. The meter still writes the row, marked `aborted`, with the tokens estimated from the prompt and the characters that came back, and `cost_source = estimated`.

That estimate doesn't stay. The row kept the gateway's generation id, and a worker job, `settleAiCosts`, asks the gateway's [generation lookup](https://vercel.com/docs/ai-gateway/observability-and-spend/usage) for the real figures once it has settled them (after a minute, for up to three days) and overwrites the estimate with the actual cost and token counts.

## A receipt per message

A row per attempt answers "what does `memory_gate` cost per month?". The question people actually ask is different: "why did that message cost so much?" For that, every row carries a `requestId`, a uuidv7 created once where a message or a job arrives and passed down to everything it causes: the reply's steps, the tools, the embeddings inside them, and the memory work the worker does after the reply.

```
  requestId 0192f3a1-...   "invoice Talleres García for the brake job"
  │
  ├── guard_input        classify the message
  ├── chat_reply         step 0, attempt 1
  │   └── embedding      inside the buscar tool (parent_id = the tool)
  ├── chat_reply         step 1, attempt 1  ── error 429, willRetry
  ├── chat_reply         step 1, attempt 2  ── ok
  ├── chat_reply         step 2, attempt 1  ── ok, the preview
  │
  └── after the reply, in the worker
      ├── memory_gate    worth remembering?
      ├── memory_extract the fact
      └── embedding      the fact, for search
```

*An example of the rows one message leaves: the reply's steps and attempts, the calls inside its tools, and the memory work after it, all under one requestId.*

`costOfRequest(db, requestId)` returns that list in order with its total, and `costOfConversation` adds requests up per chat. The staff console uses the same queries to list the most expensive requests and conversations of a period, each opening into its timeline of attempts with tokens, cost, errors and retries.

Tools and actions get costs the same way, without copying a single token count. A view joins each tool call to the model calls made inside it (by `parent_id`) and to the reply step that asked for it:

```sql:packages/db/drizzle/0041_ai_rls.sql
CREATE OR REPLACE VIEW ai_tool_call_costs AS
SELECT t.id AS tool_call_id, t.request_id,
	count(e.id)::int AS model_calls,
	coalesce(sum(e.cost_nano_usd), 0)::bigint AS cost_nano_usd
FROM ai_tool_calls t
LEFT JOIN ai_usage_events e ON e.request_id = t.request_id
	AND (e.parent_id = t.id OR (t.model_call_id IS NOT NULL AND e.call_id = t.model_call_id AND e.step = t.step_index))
```

There is a catch in that join, and the project notes spell it out. When one reply step asks for two tools, that step's cost counts toward both, so the tools of a request can add up to more than the request. The per-tool figure answers "what did this tool's turn cost?". The total is always the ledger's sum by `requestId`.

## Caps that read counters, not the ledger

Knowing the cost after the fact is half of it. The other half is refusing a call before it's made, when a person or an organization is over their limit. Summing the ledger before every call would put a Postgres aggregate in front of every reply, so the check reads counters instead.

Every row's cost is added to a Redis hash per scope and period: global, the organization, the person, the WhatsApp number, and the person within the organization, each by day and by month. Each hash has a field for everything, one for what is billed to people, and one per feature:

```
  attempt: chat_reply, cost c, org A, user U, on the web
     │
     ├──▶ ai:spend:global:<day>         all · billable · f:chat_reply
     ├──▶ ai:spend:org:A:<day>          all · billable · f:chat_reply
     ├──▶ ai:spend:user:U:<day>         all · billable · f:chat_reply
     ├──▶ ai:spend:ou:A:U:<day>         all · billable · f:chat_reply
     └──▶ ... and the same four for the month
```

*One attempt's cost lands in up to five scopes, each with a daily and a monthly hash; a budget reads the field it covers.*

A budget is a row in `ai_budgets`: a scope, a day or a month, an amount in euros or dollars, a warning percentage, whether it blocks, and optionally which features it covers. Before each call the meter reads the hashes of every hard budget that covers the feature and refuses if one is spent. The refusal is itself a row, with status `blocked_by_budget`, so the ledger also shows the calls that never happened.

Counters drift: an increment lost in a crash, a Redis that was flushed. So a worker job replaces every counter of the current day and month with the ledger's own sums every five minutes, and the ledger stays the source of truth:

```typescript:packages/server/src/ai/usage/budgets.ts
/**
 * Replaces every counter of the current day and month with the ledger's sums: increments a
 * crash lost, or a Redis that was flushed, come back. Increments between the query and the
 * write are lost until the next run (minutes).
 */
export async function reconcileAiCounters(at = new Date()): Promise<{ keys: number }> {
```

Two choices in the check are deliberate. If Redis can't be read, the call goes through and the error is logged: an outage in the counters shouldn't take the assistant down with it, and the reconciliation catches up within minutes. And a limit is checked before a call, never during it, so a long answer that starts just under a limit can finish just over it. A cap here is a cap on starting new work, which for limits of a few euros a month is the precision that matters.

### Who gets refused, and how

Not every feature should answer to the same limits. The registry splits them into two classes:

- **Interactive features answer to everyone's budget:** someone is waiting (a reply, a title, a voice note), so the call is checked against the person's, the number's and the organization's limits. At a hard limit the web chat refuses before it starts streaming, with a message the person can act on: "You've reached your Suma usage limit this month. Your administrator can raise it in Settings."
- **Background features answer only to the organization:** embeddings, memory and OCR never block anyone's reply. When the organization's budget is spent they throw an error marked `paused`, and the job puts its work back in the queue instead of counting a failure.

```typescript:packages/server/src/search/indexer.ts
if (AiBudgetExceededError.is(error) && error.paused) {
  // The organization's AI budget is spent: its work waits, without counting as a failure.
  await db.execute(sql`update search_outbox set available_at = now() + interval '15 minutes' where id = ${row.id} and version = ${row.version}`);
  continue;
}
```

The search index retries the organization's backlog every fifteen minutes; document reading pauses the organization for ten. When the month turns over, or someone raises the limit, the backlog drains on its own.

Every organization without a monthly limit of its own gets its plan's default: 1 euro for the trial, 1.50 for the basic plan and 4 for Pro, a warning at 80% and a hard stop at 100%. The plans don't yet say how much AI they include, so those amounts are a starting point. Crossing the warning sends one email to the organization's owners and admins (and to the person, if the limit is theirs), recorded in a notices table so it goes out once per limit and period. In settings, owners see the organization's spend by feature in plain names and a table of members with their spend and limits, where they can set or remove a monthly cap for one member or many at once, each change in the audit log.

## What the receipts changed

The ledger earned its keep the first week, on a decision that would otherwise have been made on vibes: which model answers the chat.

With every call priced, comparing models was cheap to do properly. I ran the real prompt and the real tools (twelve of them) through the same eleven typical turns on each candidate, in development on 20 October 2026, one pass per model, for about $0.28 in total. The costs are per turn, with the cached price in parentheses:

| Model, no reasoning | First token, simple turns (p50) | Right tool | Cost per turn |
|---|---|---|---|
| openai/gpt-6-sol | 0.9 s | 10/11 | $0.0063 ($0.0054) |
| openai/gpt-5.6-luna | 0.9 s | 11/11 | $0.0006 ($0.0005) |
| openai/gpt-6-luna | 2.0 s | 9/11 | $0.0003 |
| openai/gpt-5.4-mini | 1.6 s | 8/11 | $0.0022 ($0.0019) |

The run was OpenAI-only for a reason that has nothing to do with price: the registry refuses models from providers that may process personal data in China for any feature marked `personalData`, and every feature is. A first round the day before had included Xiaomi's MiMo models, the default at the time among them ($0.00076 per turn); they were dropped for that reason, not for their numbers.

What came out of it is two reply models. A rule in code, with no model call, sends greetings and simple questions about what Suma remembers to `gpt-5.6-luna`, and everything else to `gpt-6-sol`. The strong model costs about seven times more per turn than the MiMo model it replaced, and I accepted that knowing the exact figure, not because a pricing page made it look small.

The ledger also caught what the cache was worth. OpenAI only [caches prompt prefixes](https://developers.openai.com/api/docs/guides/prompt-caching) of 1,024 tokens or more, and on the second call of a conversation 6,321 of 6,324 input tokens came from the cache, taking a `gpt-6-sol` turn from $0.016 to $0.0014. In the eleven-turn rounds, 90 to 95% of the input came from the cache. One bug showed up only because the ledger records cached tokens per row: temporary conversations had zero cached tokens (0 of 7,505) because their memory profile changed on every turn. After the fix, the second turn of a temporary chat read 5,376 of 9,171 tokens from the cache.

## The price of a receipt

The cost of all this is an insert per attempt into a table indexed eight ways, a Redis write to up to ten hashes, and a worker that re-sums the day and the month every five minutes. Raw rows are kept for 180 days; a daily table keeps the totals by day, organization, person, number, channel, feature and model for good, recomputed for today and yesterday every ten minutes.

What it buys is that every number in the system has a source I can point at. "Memory costs $0.08 per 1,000 messages" in [the memory post](/blog/agent-memory-across-channels) is a sum over these rows, not a calculation. A limit stops a person at the amount the organization set, give or take one answer. And when the provider's invoice arrives, the question is no longer what the total was spent on, but whether the ledger's total matches it.
