launch-quickly

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 layer

Each 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, requireAiBudget throws PAYMENT_REQUIRED when the month's budget is spent; your normal error path carries the sentence to the user.
  • After it, recordAiUsage writes one ai_usage_events row 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 on ai_usage_events, a branch in requireAiBudget. 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 → generateAiImage returns bytes; store them through the files layer so tenancy and limits apply like any upload.

On this page