AI
The provider seam, streaming, and a usage budget wired into billing.
Every model call in a launch-quickly app goes through src/lib/ai/ — features
never import the ai-sdk directly. One choke point is what turns "every call is
gated and metered" from a habit into a property of the codebase.
Three entry points
streamAiChat(ctx, { messages }); // token-by-token chat — a route handler
generateAiText(ctx, { prompt }); // one-shot generation — an action or a job
generateAiImage(ctx, { prompt }); // images — pair with the files layerEach takes a TenantContext first, like a query does. An AI call is spend,
and spend belongs to an organization.
Streaming belongs to route handlers. A server action resolves once; the
SDK's UI message stream is a Response the browser consumes as tokens arrive,
and useChat speaks that format natively. The shipped example is
src/app/api/ai/chat/route.ts plus the assistant feature — session
authentication, per-organization rate limiting, and payload validation with
the SDK's own safeValidateUIMessages rather than our approximation of its
message shape.
The provider seam
src/lib/ai/provider.ts is the only file that names a vendor. Text is served
by Anthropic, images by OpenAI — Anthropic has no image model, which is why
two providers exist at all. Model ids come from the environment
(AI_TEXT_MODEL, AI_IMAGE_MODEL), so a model deprecation is a config
change, not a deploy.
Unconfigured is a supported state, same as Stripe and email: textModel()
throws an error naming the missing variable, the assistant page renders an
explanation instead of a broken chat, and the chat route answers 503 with the
same sentence. A fresh clone builds and runs with no AI keys at all.
The budget is an ordinary entitlement
aiTokensPerMonth and aiImagesPerMonth are limits in
src/config/billing.ts, next to projects and seats. There is no second
billing system to keep honest — a plan change re-prices AI the way it
re-prices seats.
Inside the entry points:
- Before the call,
requireAiBudgetthrows PAYMENT_REQUIRED when the month's budget is spent; your normal error path carries the sentence to the user. - After it,
recordAiUsagewrites oneai_usage_eventsrow with the provider's own token counts. When a provider omits counts, zero is recorded rather than guessed — an honest under-charge beats an invented number in every dispute. - An organization can overshoot by at most one call. Deliberate: gating on a pre-call estimate refuses work the budget could actually cover, and refusing paid-for work is the worse failure.
Usage rows are tenant data with their own RLS policy. Reading another
organization's AI spend would be a confidentiality bug; writing into their
meter would be a billing bug; the policy's USING and WITH CHECK close
both.
The metering contract is proven in
src/lib/ai/usage.integration.test.ts, including the test that matters:
events written past the limit make the next requireAiBudget refuse.
What is not proven
No part of this layer has ever run against a real model. Every test feeds
token counts we made up, so what is verified is the accounting — gate before,
meter after, budget refuses once spent, one row per call, RLS on both
directions. What is not verified is the step before that: whether a live
provider's response carries its counts where recordAiUsage looks for them.
If ai-sdk moves that field, or a provider returns it under another name, the meter records zero and every test still passes. The code treats a missing count as zero deliberately — a call that cannot be measured must not silently bill as if it were free-but-fine — but zero-because-absent and zero-because-cheap are indistinguishable in the table.
So the first thing to do with a real key is not to check that a completion
arrives. It is to make one call and read the ai_usage_events row.
This is recorded rather than fixed because the exercise that would fix it — a round of fleet products built with live keys — was cut from the plan. It is an honest gap, not an oversight.
Growing it
- New kind of AI spend → a limit in
billing.ts, a kind or column onai_usage_events, a branch inrequireAiBudget. All three, or the meter lies. - Retries, fallback chains, model routing → ai-sdk middleware, behind the seam. Deliberately absent until a real incident makes the case.
- Persisting generated images →
generateAiImagereturns bytes; store them through the files layer so tenancy and limits apply like any upload.