Cross-surface AI-spend monitoring, served on the admin Cost tab (/admin/ai/cost). One table merges token usage from every AI surface and prices it from a reference rate table — derived at read, never charged, never faked.
The dollars are a reference at standard pay-as-you-go rates. Our keys run free-tier, so the real charge is
$0. Same honesty stance as the provider quota board and the image reader cost panel.
What it is
/admin/ai/cost is the 6th AI admin sub-tab: Overview · Models · Usage · Cost · nRAG · Tools. It answers one question — "how many tokens did each model burn across the whole app, and roughly what would that cost?" — without ever implying a real bill.
The tab holds three things:
| Block | Source | Shows |
|---|---|---|
| Usage by Model (30 days) | chat + image telemetry, merged | per-(surface, model) token counts + reference cost |
| Image Metadata Reader health | image telemetry | analysis KPIs + a binary save/abandon funnel |
| Image Analyses per Day | image telemetry | 30-day volume bar chart (VolumeBarChart.svelte, shared with the Usage tab) |
Relocation: the chatbot per-model token table once lived on the Models tab. It now lives here, widened to cover both surfaces. The Models tab is provider routing/config only.
The cross-surface merge
Two telemetry tables feed the usage table, and they stay independent:
| Surface | Table | Counts a "call" as |
|---|---|---|
chat |
ai.conversation_step |
a step |
image |
image.ai_proposal |
an analysis |
The merge is app-layer, never SQL. buildUnifiedModelUsage(chatRows, imageRows) in src/lib/server/ai/usage-summary.ts is the only place that knows both tables exist — no UNION, no DB view, no shared schema. Each surface keeps its own query module; the seam is one pure function.
Rows are keyed on (surface, modelId). The same model can appear twice — once per surface — with different token profiles (a chat step and a vision analysis spend very differently). Busiest rows sort first; rows with no reported tokens sort last.
Columns: Surface · Model · Provider · Input · Output · Thinking · Total · Calls · Ref. cost, plus a <tfoot> totals row. Calls carries its unit (steps vs analyses) and is therefore not summed in the footer — the two units don't add up.
Honesty model
Three rules make a misleading number structurally impossible.
Cost is reference, derived, never stored
Each row's cost comes from estimateCost(modelId, tokens) in pricing.ts, keyed by modelId (vision-token cost differs ~25× across models on one provider). The estimate is computed at read from the versioned price map — token counts are the only stored fact. CostEstimate.kind: 'reference' is the machine-readable honesty bit; the summary's costKind is always 'reference'.
Partial coverage shows "—", never a fake $0
Not every model is priced — the Groq llama-3.3-70b-versatile chat model and the unknown bucket have no entry. estimateCost returns null for them, and the row renders "—", never $0.
The summary carries costCoverage: 'full' | 'partial' | 'none' and pricedRowCount. The totals row sums cost over priced rows only, and a caption states the coverage: "Cost summed over N of M priced rows — unpriced models show '—', rates as of {asOf}." A grand total that silently drops unpriced models can't masquerade as complete.
Token Total = Input + Output only
"Thinking"/reasoning tokens are a subset of output, already inside Total. They get their own column for transparency but are never added on top — doing so double-counts reasoning-heavy models by ~40%. The footer's Thinking cell is intentionally "—".
Image Metadata Reader health
A heartbeat block for the image showcase, on the same tab.
KPI tiles: successful analyses (30d), analyses today, distinct images (30d), last analysis (relative time).
Binary conversion funnel — saved vs abandoned. This is conversion, not approval quality. The code only ever writes metadata status='approved' at save time; the rejected / draft / proposed enum values exist but are never written, so there is no reject path to measure. "Abandoned" = an image was analyzed but no metadata record was saved.
Failure visibility caveat. Failed analyses (provider error, timeout, unreadable image) are not recorded. Counts are therefore labelled "successful analyses" — there is no true success rate. The tab states this limitation inline; treat the numbers as a floor.
Overview-tab heartbeat
The Overview tab gains a small Image Metadata Reader card (analysis counts) linking here. It deliberately shows no dollar figure — money stays off the glance layer so a $ next to chat counts can't be misread as "total AI spend." Dollars live only on the Cost tab, always captioned as reference.
Module map
| Piece | Location | Role |
|---|---|---|
| Merge seam | src/lib/server/ai/usage-summary.ts |
buildUnifiedModelUsage() — app-layer union + honest summary |
| Price table | src/lib/server/ai/pricing.ts |
MODEL_PRICES + estimateCost(); reference, server-only |
| Chat usage query | src/lib/server/db/ai/admin-queries.ts |
getModelUsage() — adds providerId, nullable token sums |
| Image usage queries | src/lib/server/db/ai/image-metadata-queries.ts |
getImageModelUsage / getImageVolumeByDay / getImageConversionFunnel / getImageUsageKpis |
| Client DTOs | src/lib/schemas/admin/model-usage.ts |
UnifiedModelUsageRow, ModelUsageSummary, AiSurface |
| Volume chart | src/lib/components/admin/ai/VolumeBarChart.svelte |
Shared by Usage + Cost tabs |
| Page | src/routes/[[locale=locale]]/admin/ai/cost/+page.svelte |
The tab |
A DB index image_ai_proposal_created_model_idx (created_at DESC, model_id) backs the per-model and per-day image rollups.
Related
- provider-routing.md — the quota & limits board on the Models tab; same honest-reference stance
- image-metadata.md — the image reader's own cost panel +
pricing.tsdecisions