AI Provider Configuration
The AI writing assistant in VeloCMS editor uses Google Gemini by default. Blog owners who want to use their own API keys (BYOK — Bring Your Own Key) can configure Gemini, Anthropic Claude, or OpenAI-compatible providers in Admin > Settings > AI. The router in src/lib/ai/router.ts resolves which provider to use on each request, with automatic failover when the primary is unavailable.
Provider priority chain
The router builds an ordered candidate list for each request. The first provider with a valid, working key wins. By default no setup is needed: the platform's own Gemini key serves every request, and your own provider keys (BYOK) are optional. Platform Gemini is always the last fallback — if the tenant has no BYOK keys configured and the platform key exists, Gemini handles the request. If no provider is available, the router throws and the editor shows an error.
| Priority | Provider | Key source |
|---|---|---|
| 1 | Tenant primary BYOK | site_settings.ai_provider_primary + matching key field |
| 2 | Tenant secondary BYOK | Remaining BYOK keys (gemini → anthropic → openai order) |
| 3 | Platform default | GEMINI_API_KEY env var |
| 4 (error) | No provider | Router throws — editor shows error state |
Supported providers
| Provider | ProviderName value | Key field in site_settings | Notes |
|---|---|---|---|
| Google Gemini | gemini | ai_gemini_key | Platform default provider; built-in model gemini-3.6-flash (editor and long-form) |
| Anthropic Claude | anthropic | ai_anthropic_key | Built-in models: claude-sonnet-5-5 (editor), claude-opus-4-5 (long-form) |
| OpenAI / compatible | openai | ai_openai_key | Built-in models: gpt-4o (editor and long-form); works with OpenAI-compatible endpoints (ai_openai_base_url) |
| Custom OpenAI-compatible | custom | ai_openai_key + ai_openai_base_url | Ollama, Together AI, Groq, etc. |
Each provider has a built-in model id, set in src/lib/ai/providers. To use a different model on your own key, set the model id in Admin > Settings > AI (stored as site_settings.ai_model); it overrides the built-in model for your site.
BYOK key storage
BYOK API keys are stored in the site_settings collection encrypted with AES-256-GCM, and an encrypted value carries a prefix that says which scheme produced it. Keys saved today use the envv2: envelope format: each tenant gets its own data key, derived from a platform-level master key with HKDF-SHA256, and the ciphertext is bound to that tenant so it cannot be moved to another tenant's record. Older keys keep the legacy enc: format, which encrypts with a single platform key stored in the ENCRYPTION_KEY environment variable. Both formats coexist while existing records are migrated. The router's resolveKey() helper recognises either prefix and decrypts transparently — callers see a plain key string and never handle the stored format directly. A value with no prefix is treated as plaintext and passed through as-is; in production that logs a warning.
import { decryptAny, isAnyEncrypted } from "@/lib/crypto/encrypt";
/** Decrypt an envv2: (per-tenant) or enc: (legacy) key; plaintext passes through. */
async function resolveKey(raw: string): Promise<string> {
if (!raw) return "";
if (isAnyEncrypted(raw)) return decryptAny(raw);
return raw; // plaintext fallback (logs a warning in production)
}
async function buildProvider(
name: ProviderName,
rawKey: string,
baseUrl?: string,
model?: string
): Promise<AIProvider | null> {
const key = await resolveKey(rawKey);
if (!key) return null;
switch (name) {
case "gemini": return new GeminiProvider(key, model);
case "anthropic": return new AnthropicProvider(key, model);
case "openai":
case "custom": return new OpenAIProvider(key, baseUrl, model);
}
}Failover triggers
Not all errors should trigger failover — an invalid key will fail on every provider in the chain, making failover pointless. The router distinguishes between failover-eligible and non-failover error types.
| Error type | Failover? | Reason |
|---|---|---|
| RATE_LIMIT | Yes | Provider-side throttle — next provider may be clear |
| NETWORK | Yes | Transient connectivity — next provider may be reachable |
| MODEL_UNAVAILABLE | Yes | Model temporarily down on that provider |
| UNKNOWN | Yes | Unknown — worth trying next provider |
| INVALID_KEY | No | Key is wrong — failover won't fix it |
| CONTENT_FILTERED | No | Content policy violation — not a provider issue |
| QUOTA_EXHAUSTED | No | Platform daily quota — not a provider issue |
Streaming architecture
AI streaming uses a Route Handler at /api/ai/generate — not a Server Action. Server Actions cannot stream responses (P-013). The route receives the prompt via POST, calls streamWithFailover() from the router, pipes the response through the stream supervisor, and forwards chunks to the client as Server-Sent Events. The HUMANIZE_DIRECTIVE from src/lib/ai/gemini.ts is appended to every system prompt for public-facing content generation.
// Content-Type: text/event-stream
// Each chunk is sent as: data: {"text":"..."}
// Completion: data: [DONE]
// Pre-stream error: data: {"error":"..."}
// Mid-stream failure: data: {"event":"stream_failed","reason":"idle_timeout","retryable":true}
export async function POST(request: Request) {
const { prompt, tenantId, taskType } = await request.json();
// ... sanitize prompt via sanitizeUserPrompt()
const stream = streamWithFailover({ prompt: sanitized, tenantId, taskType });
return new Response(toSSEStream(stream), {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
});
}Prompt injection mitigation
All user-supplied prompts pass through src/lib/ai/prompt-sanitizer.ts before reaching the LLM. The sanitizer truncates to 2000 characters, strips injection-signal phrases (ignore previous instructions, DAN mode, etc.), removes ChatML and Llama structural markers, and strips invisible zero-width characters. The output is then validated by src/lib/ai/output-validator.ts, which blocks responses that contain the system prompt, Stripe key shapes, or encrypted secret prefixes. Only validated output reaches the client.
import { sanitizeUserPrompt } from "@/lib/ai/prompt-sanitizer";
import { validateAiOutput, OUTPUT_BLOCKED_MESSAGE } from "@/lib/ai/output-validator";
const sanitized = sanitizeUserPrompt(rawUserInput);
if (sanitized.modified) {
// Log flags only — never log the raw input (contains sensitive patterns)
console.warn(`[ai-sanitizer] flags=${sanitized.flags.join(",")}`);
}
// ... generate with sanitized.cleaned ...
const validation = validateAiOutput(fullResponseText);
if (!validation.valid) {
// Replace with generic apology — never reveal what was blocked
return OUTPUT_BLOCKED_MESSAGE;
}Configuring AI in Admin
- Navigate to Admin > Settings > AI
- Select your primary provider from the dropdown (Gemini, Anthropic, OpenAI)
- Paste the API key for that provider — VeloCMS encrypts it on save
- Optionally add secondary provider keys as fallback
- For custom OpenAI-compatible endpoints, set the base URL in the Custom Endpoint field
- Click Save — the editor AI panel will use the new provider on the next request
Reference
| File | Responsibility |
|---|---|
| src/lib/ai/router.ts | Multi-provider failover router — buildCandidateList(), streamWithFailover() |
| src/lib/ai/gemini.ts | Singleton Gemini client, SYSTEM_PROMPT, HUMANIZE_DIRECTIVE constant |
| src/lib/ai/client.ts | BYOK multi-provider factory — streamGenerate() |
| src/lib/ai/stream-supervisor.ts | Mid-stream failure detection (idle timeout, max bytes, provider error) |
| src/lib/ai/prompt-sanitizer.ts | Input sanitizer — injection signal stripping |
| src/lib/ai/output-validator.ts | Output validator — system prompt leak detection |
| src/lib/ai/rate-limit.ts | Per-tenant sliding-window rate limiter |
| src/app/api/ai/generate/route.ts | SSE streaming endpoint |