/admin/cost is a single-page operational view of how much your AI providers
are costing you. It reads the ai_call table (every call your app makes is
logged there with token counts, cost in micro-cents, and latency), rolls up
the last 30 days, and breaks it down per provider/model. Auth-gated by
requireAdmin() in the layout — only admins reach the route.
Prerequisites
- AI calls already flowing through the provider abstraction (Phase 2). The page is empty until your first call lands.
- Your user record has
role = 'admin'. Set it via SQL or the admin user page. - The
ai_calltable exists — created bypnpm db:pushaftersrc/db/ai.schema.ts. - Per-model price book entries (otherwise cost shows as 0 — see pitfall 3).
Step-by-step
- Sign in as an admin at
/login, then visit/admin/cost. - Read the four summary cards at the top:
- Total calls (last 30d)
- Total tokens (input + output)
- Total spend (rendered through
<CostBadge>, micro-cents to dollars) - Success rate (% of calls with
status = 'success')
- Drill into the per-model table below. Rows are sorted by spend
descending. Columns:
provider/model, calls, input tokens, output tokens, cost (CostBadge), avg TTFT (ms), error rate (%). - Spot the offenders — high error rate, slow TTFT, or runaway spend.
- Investigate in
/admin/ordersor by queryingai_calldirectly withpnpm db:studio.
How the rollup works
The page runs two SQL aggregates againstai_call:
(provider, model) and orders by
sum(cost_micro_cents) desc. Both queries window on
createdAt >= now() - 30d.
Verify it works
- Trigger an AI call from anywhere in the app (e.g. a test prompt).
- Refresh
/admin/cost— the call count goes up by one, tokens reflect the request/response sizes, and cost is non-zero (assuming the model is in your price book). - Confirm the model appears in the per-model table with the right provider.
- If you have multiple providers configured, run a few calls against each; the rows sort by spend so the most expensive model bubbles up.
Common pitfalls
- Empty dashboard until you make AI calls. New install? Of course it’s
empty. The page only reads from
ai_call— no calls, no rows. - Cost shown as 0. Your
cost_micro_centscolumn is 0 because the model has no entry in the price book. Add it undersrc/ai/pricing.ts(Phase 2) — or accept the discrepancy if you only care about token counts. - Micro-cents math. Stored cost is in micro-cents to avoid floating-
point errors.
<CostBadge>divides by 1,000,000 to render dollars. If you query the table directly, remember to do the same:cost_micro_cents / 1_000_000.0for USD. - Slow queries on large datasets. Past ~1M
ai_callrows the per-model group-by gets sluggish. Add a Postgres index on(created_at, provider, model)or partition by month. - 30-day window is hardcoded. Want a different window? Edit
WINDOW_MSat the top ofsrc/app/[locale]/(app)/admin/cost/page.tsx. There’s no query-string toggle yet.
Official docs
- Architecture and provider abstraction: /docs/architecture
- Drizzle ORM (queries used here): orm.drizzle.team
- Postgres window functions: postgresql.org/docs/current/functions-window.html
- Postgres partitioning: postgresql.org/docs/current/ddl-partitioning.html