AI
Overview
The kit ships a base structure for AI, not an AI feature: a provider
abstraction in src/lib/ai/ (same pattern as email and payments) that resolves
the configured model, plus one reference procedure (ai.generate) showing
how to meter a model call with credits end-to-end. What you build with the
model — chat, extraction, agents, streaming — is your product's decision, along
with every parameter you pass.
Everything is off until you enable it:
AI_ENABLED=true
AI_PROVIDER=anthropic # anthropic | openai | google
ANTHROPIC_API_KEY=sk-ant-...
# AI_MODEL=claude-opus-4-8 # optional override; defaults per provider
If the selected provider's key is missing, the app fails at boot (Zod
validation in src/env.ts). Default models live in src/config/ai.config.ts
(claude-sonnet-4-5 / gpt-4o-mini / gemini-2.5-flash).
Using the model
Two entry points, both in src/lib/ai/ — AI SDKs are imported only there
(the same rule as Stripe and Resend):
generateAiText(opts) — non-streaming convenience with normalized usage:
import { generateAiText } from "@/lib/ai";
const { text, usage } = await generateAiText({
prompt: "Summarize this…",
system: "You are terse.", // optional
maxOutputTokens: 512, // optional — you decide the parameters
});
// usage: { inputTokens, outputTokens, totalTokens } — always numbers
resolveLanguageModel() — the escape hatch for everything else. It returns
the configured AI SDK LanguageModel, so any SDK capability works: streaming,
tools, structured output…
import { streamText } from "ai"; // allowed inside src/lib/ai only —
import { resolveLanguageModel } from "@/lib/ai"; // build your feature module there
const result = streamText({ model: await resolveLanguageModel(), prompt });
Metering with credits — the reference wiring
ai.generate (tRPC, any org member) is the blueprint for charging model usage
through the credits lifecycle:
- Estimate before any work:
~chars/4input tokens + the output cap, priced viaestimateCredits("ai.chat", tokens/1000)(cost catalog incredits.config.ts). - Reserve → generate → settle with
withCreditHold: the hold is taken atomically (throwserrors.insufficientCreditsbefore calling the model if the balance can't cover it), the model runs, and the hold settles with the actual cost computed from the provider-reportedusage.totalTokens— unused estimate refunded, failures release the hold. - The response includes
creditsChargedso the client can show real cost.
Copy this shape for your own AI features: keep the model call inside your procedure, estimate generously, settle with real usage.
Testing without network
ai/test ships MockLanguageModelV4 — inject it through generateAiText's
model option to test AI-consuming code with canned responses and exact token
usage (see src/lib/ai/index.test.ts for the pattern).
Extending
- Change models per call: pass your own
modeltogenerateAiText, or useresolveLanguageModel()and the SDK directly. - New provider: add a module under
src/lib/ai/providers/, extend theAI_PROVIDERenum inenv.tsand the defaults inai.config.ts. - Pricing: tune the
"ai.chat"entry (or add operations) in thecreditCostscatalog.
The chat module (AI_CHAT_ENABLED)
A reference streaming, tool-calling, credit-metered chat at /chat,
off by default. Enable with AI_CHAT_ENABLED=true (requires AI_ENABLED);
behaviour (step caps, output caps, tool approval policy, rate limit) lives in
src/config/ai-chat.config.ts.
What it solves once so products don't improvise it:
- Streaming metering. Each turn RESERVES credits before any token streams (insufficient balance → refused before spend), SETTLES with the provider-reported usage when the stream finishes, and RELEASES the hold on error or abort. The estimate scales by the step cap because a tool loop re-sends the prompt per step — generous on purpose, the remainder refunds.
- Tool safety. Tools are approval-required by default: the model's request
to run one pauses the stream and renders an allow/deny prompt in the UI
(signed approval state — a client can't forge one). Auto-approve read-only
tools via
autoApprovedTools. Every EXECUTED call lands in the audit log with hashed args. - Tools come from the product seam (
src/product/ai-chat.ts): in-process AI SDKtool()s, and/or MCP servers — the kit connects provider-agnostically (@modelcontextprotocol/sdk+dynamicTool), so any provider can call MCP tools. MCP auth tokens are resolved server-side; user-configured MCP URLs are re-checked against the shared SSRF guard (src/lib/net/ssrf.ts) on every connect.
Conversations persist per (org, author) as structured UIMessage parts
(AiConversation/AiMessage), so tool interactions replay faithfully when a
conversation reloads.