AI
Set up an AI provider for the store and build AI features that still work when no provider is configured.
Litestore's AI features call Google Gemini, Anthropic Claude or OpenAI through the Vercel AI SDK, depending on which provider you set in AI_PROVIDER. Read this page when you configure a provider key or build a new AI feature.
Litestore doesn't bundle any AI. When a store has no provider key, it hides the AI features instead of failing. AI is optional in a self-hosted store, so every feature that uses it must be able to disappear cleanly.
Providers
export type Provider = "google" | "anthropic" | "openai"
const DEFAULT_PROVIDER: Provider = "google"
// Use GA / stable, low-latency models — NOT `-exp` previews, which Google shuts down
// on a rolling basis (that's how "AI Connected" ended up pointing at a dead endpoint).
const DEFAULT_MODELS: Record<Provider, string> = {
google: "gemini-2.0-flash",
anthropic: "claude-sonnet-4-20250514",
openai: "gpt-4o",
}| Provider | Default model | Key |
|---|---|---|
google (default) | gemini-2.0-flash | GOOGLE_GENERATIVE_AI_API_KEY |
anthropic | claude-sonnet-4-20250514 | ANTHROPIC_API_KEY |
openai | gpt-4o | OPENAI_API_KEY |
The comment about preview models records a real incident. The provider retired an -exp model, and the store was left pointing at a dead endpoint. That is why the defaults are GA models.
Litestore treats a provider as configured when its key is present and at least 20 characters long. It treats a shorter value as a placeholder or a cut-off paste, and AIConfigError reports that instead of letting the provider fail the request with an unhelpful message.
Configuration
| Variable | What it does | Default |
|---|---|---|
AI_PROVIDER | Chooses the provider. Litestore falls back to the default for an unknown value, so a typo switches the store to Google instead of taking the store down. | google |
AI_MODEL | Overrides the model, but only when its provider is the active one. | The provider's default model |
AI_FALLBACK_ENABLED | Turns failover on or off. Only the exact value "false" turns it off. | On |
AI_MODEL only applies to the active provider because model names belong to one provider. Applying it to a fallback provider would send a Gemini model id to Anthropic.
Litestore reads AI_FALLBACK_ENABLED from process.env, not from the validated env object.
Failover
Each provider has exactly one successor:
google → anthropic → openai → googleFallback goes one step, not around every provider. If the successor has no key, or its key is shorter than 20 characters, Litestore has no fallback model and rethrows the original error. Litestore only checks the primary provider's key up front, so a missing successor key means there is no fallback, not a startup failure.
shouldFallback determines whether an error moves on to the successor. It looks at the typed signal first.
Status code
If the error is an SDK APICallError, it uses the status code.
Authentication errors
On 401 or 403, it doesn't fall back.
Rate limits and server errors
On 429 or any 5xx, it falls back.
Other status codes
For any other status, it uses the SDK's own isRetryable flag.
Errors without a status
For errors such as network failures, it matches the message and error name against a list of patterns.
A 401 or 403 deliberately does not fall back. A rejected credential is a configuration mistake, and quietly spending money at the next provider would hide it.
Checking the status first also keeps working after SDK upgrades. If Litestore matched message text, a provider rewording "rate limit" would silently break fallback, and nobody would spot the problem until the bill arrived. Litestore only uses the pattern list for errors that have no status at all:
rate limit · rate_limit · too many requests · 429 · 503 · 502 · 500
timeout · timed out · ECONNRESET · ENOTFOUND · socket hang up
network error · service unavailable · overloaded · capacityStreaming
Your code awaits generateObject and generateText, so it can catch their errors where it calls them and retry with the fallback provider.
streamText works differently. It returns right away, and its API errors only appear while something downstream reads the stream, long after the wrapper has returned. A try/catch around streamText therefore never caught the errors it was written for. Those errors slipped past it, and the SDK reported a generic "An error occurred" in the stream.
The streaming path now does what the SDK can actually do. maxRetries: 3 retries the same provider with exponential backoff before the stream starts, which genuinely recovers from a brief outage. onError logs streaming errors instead of swallowing them, and notes whether the error would have justified a fallback.
When a call finishes, every path records its cost against the monthly budget (recordAiSpend in modules/assistant/spend.ts) and sends a usage event to PostHog with the provider, the resolved model, the token counts and whether the call fell back. If telemetry fails, Litestore logs the failure and moves on, and the caller still gets the generated result.
Limits and budget
Two ceilings from platform/config/ai.ts apply to every call:
| Limit | Default | What happens when it's reached |
|---|---|---|
AI_RATE_LIMIT | 20 requests per user per minute on the admin AI routes | 429 with Retry-After. This limiter fails closed: if Redis is unreachable, AI requests are refused rather than let through. |
AI_MONTHLY_BUDGET_USD | $50 per calendar month (UTC), store-wide | assertWithinAiBudget refuses every model call until the month rolls over, with a 429 and a readable message. 0 means unlimited. |
Spend is computed from token counts and AI_MODEL_PRICES. A model missing from the price table is priced as the most expensive listed one, so the budget can only trip early.
Grounding
verifyGroundedOutput in modules/assistant/grounding.ts checks AI text that a person will act on or send. A model asked to write from known facts can still invent the specifics a reader trusts most, such as an amount, an order number or a date, and every one of those carries a digit. The check pulls each digit-bearing token out of the output and requires it to appear in the facts the caller fetched.
Fill with AI on the product page holds back a specification whose number isn't in the operator's inputs, and lists copy that carries one for review. The support-reply draft in Messages re-checks its draft the same way. The function is pure, so each caller decides what to do with a failure.
No provider configured
withAdminAI in modules/assistant/admin-ai-route.ts returns 501 with "AI features are not configured". In the UI, every AI affordance (the assistant on the admin home, Fill with AI on the product page, the URL import option and the reply draft in Messages) is wrapped in <AiOnly> or checks useAiEnabled() from modules/assistant/ai-status-provider.tsx, so it stays hidden. Without a key, the admin home shows the To do counts instead of the assistant.
Both routes under app/ai/v1/ (admin-chat and fill-product) use withAdminAI as their shared wrapper, and the 501 is only one of its checks. A request must pass these checks in order:
| Check | What fails it | Response |
|---|---|---|
Admin authentication and the ai:view permission | No admin session or no permission | Rejected |
| A configured provider | No provider key | 501 |
| The per-user AI rate limit | Too many requests | 429 with Retry-After |
| Zod validation of the request body | Invalid input | 400 with the list of issues |
| A safety check on the last user message | Blocked content | 400 with blocked |
Litestore translates SDK errors instead of passing them through. A network failure becomes 503 and an unknown model becomes 404. A content-filter block becomes 400, the monthly budget and provider rate limits become 429, a provider quota error becomes 402, and anything else becomes 500 with the provider's message cut to 200 characters.
What ships
The AI features are the admin assistant (/ai/v1/admin-chat), Fill with AI on the product page (/ai/v1/fill-product: description, specifications, tags and search title and description, as a prefill that persists only on save), product extraction for Import from URL, the support-reply draft in Messages, and tag suggestions on the Tags page. Litestore doesn't generate image alt text or content in bulk across a collection, and there is no diff view, because in-form generation prefills the form.
Adding AI features
withAdminAI
That wrapper already handles the configured check, the permission gate, input validation, the safety check and error translation. If you rewrite any of them yourself, your feature will behave differently from the rest.
Fallback wrappers
Call generateObjectWithFallback or generateTextWithFallback instead of calling generateObject or generateText directly. The wrappers pick the model, handle the failover step and record usage.
streamTextWithFallback
Use streamTextWithFallback for anything that streams, and note that it retries the same provider instead of switching.
Hiding AI controls
Wrap the control in <AiOnly> (or check useAiEnabled()) in the UI as well as relying on the route. A 501 is the right answer to a request, but an operator with no key shouldn't see a button that can only fail.
When you wire up a new AI feature, don't ask whether it works with your key. Ask this instead: what does this feature look like on a store that has no AI configured at all?
Related
Agents
Read tools answer inside the assistant's loop, change tools only mint approval cards, and the build rejects an execute tool on a conserved permission.
MCP
Shoppers' assistants reach the public MCP tools at /api/mcp, and the admin's gated tool kit answers at /api/mcp/admin behind an admin:mcp key.
Configuration
The env.ts file lists the required environment variables and the optional groups, and each optional group configures one service.