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. 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 | Default for platform; supports gemini-2.0-flash-exp |
| Anthropic Claude | anthropic | ai_anthropic_key | claude-3-5-sonnet-20241022 default |
| OpenAI / compatible | openai | ai_openai_key | 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. |
BYOK key storage
BYOK API keys are stored in the site_settings collection encrypted with AES-256-GCM using the ENCRYPTION_KEY environment variable. The enc: prefix marks an encrypted value. The router's resolveKey() helper decrypts transparently — callers see a plain key string and never interact with the enc: format directly.
import { decrypt, isEncrypted } from "@/lib/crypto/encrypt";
/** Safely decrypt an enc: prefixed key, or return as-is if plain. */
function resolveKey(raw: string): string {
if (!raw) return "";
return isEncrypted(raw) ? decrypt(raw) : raw;
}
function buildProvider(
name: ProviderName,
rawKey: string,
baseUrl?: string
): AIProvider | null {
const key = resolveKey(rawKey);
if (!key) return null;
switch (name) {
case "gemini": return new GeminiProvider(key);
case "anthropic": return new AnthropicProvider(key);
case "openai":
case "custom": return new OpenAIProvider(key, baseUrl);
}
}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 |