ai_call per attempt —
success, error, or cancelled — including tokens, cost, latency, and the prompt version
that drove it. That single table powers the admin dashboard at /admin/cost and is all
you need to spot a runaway model or a broken provider.
Prerequisites
- Migrated DB so the
ai_calltable exists (pnpm db:push). - A few real AI calls behind you — the table is empty until then.
- Read
src/ai/manager.tsrecordCall()— that’s where every row is born.
The columns that matter
userId, (provider, model), status, createdAt, promptId cover the
queries the dashboard runs.
Why micro-cents?
OpenAI charges $0.0001 per 1k input tokens for some models. A 200-token call costs 0.002¢. Storing in cents would force floats and round-off. Micro-cents = 1/100 of a cent, so:- $1.00 = 100¢ = 10_000 micro-cents
- $0.0001 = 0.01¢ = 1 micro-cent
- Display in dollars:
microCents / 1_000_000
<CostBadge /> primitive does this conversion. Don’t roll your own.
Step-by-step: add a usage report
You’ll write three queries: daily cost, per-model error rate, p95 latency. All run straight againstai_call.
Daily spend, last 30 days
Error rate per model
p95 total latency per model
The admin dashboard
/admin/cost (gated by adminActionClient → user.role === 'admin') reads
ai_call and renders these three queries plus a leaderboard of top spenders. To add
your own widget, drop a server component in src/app/[locale]/(app)/admin/cost/,
import db, run a query, render. There’s no extra wiring.
Verify it works
- Make a chat call — any model, any provider.
psqland runSELECT provider, model, status, cost_micro_cents, total_ms FROM ai_call ORDER BY created_at DESC LIMIT 1;— one row.- Visit
/admin/cost(signed in as admin). The “today” total should reflect your call. - Force an error: pass a bogus model id. Re-check the table — a
status = 'error'row appears witherrorMessagepopulated.
Common pitfalls
- Forgetting a
pricing.tsentry → cost = 0.getTokenPricefalls back tomock:any, which still emits a tiny number, but anything you charge real money for should have an explicit row. Audit withSELECT model, SUM(cost_micro_cents) FROM ai_call GROUP BY model HAVING SUM(cost_micro_cents) < 100; - Confusing micro-cents with cents (off by 100). Spent two hours wondering why margins look amazing? You divided by 10_000 instead of 1_000_000.
- Ignoring failed calls. They cost too — Anthropic charges for failed streams
past TTFT. Filter
status = 'success'only when you mean “what worked”; for cost, sum over all rows. - Cached rows look free. The manager sets
cached = trueandcostMicroCents = 0for cache hits. That’s correct — you didn’t pay — but if you’re sizing capacity, exclude cached rows from latency reports too. ttftMsnull for non-streaming. It’s only populated foroperation = 'chat_stream'. Don’t aggregatettftMsover chat (non-streaming) rows; they’re allNULL.
Official docs
- PostgreSQL aggregates: postgresql.org/docs/current/functions-aggregate.html
percentile_cont: postgresql.org/docs/current/functions-aggregate.html#FUNCTIONS-ORDEREDSET-TABLE- Drizzle raw SQL: orm.drizzle.team/docs/sql
- Source:
src/db/ai.schema.ts,src/ai/manager.ts