AI Provider Configuration

10 min readUpdated 27 Apr 2026

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.

PriorityProviderKey source
1Tenant primary BYOKsite_settings.ai_provider_primary + matching key field
2Tenant secondary BYOKRemaining BYOK keys (gemini → anthropic → openai order)
3Platform defaultGEMINI_API_KEY env var
4 (error)No providerRouter throws — editor shows error state

Supported providers

ProviderProviderName valueKey field in site_settingsNotes
Google Geminigeminiai_gemini_keyDefault for platform; supports gemini-2.0-flash-exp
Anthropic Claudeanthropicai_anthropic_keyclaude-3-5-sonnet-20241022 default
OpenAI / compatibleopenaiai_openai_keyWorks with OpenAI-compatible endpoints (ai_openai_base_url)
Custom OpenAI-compatiblecustomai_openai_key + ai_openai_base_urlOllama, 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.

src/lib/ai/router.ts (key resolution)
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);
  }
}
typescript

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 typeFailover?Reason
RATE_LIMITYesProvider-side throttle — next provider may be clear
NETWORKYesTransient connectivity — next provider may be reachable
MODEL_UNAVAILABLEYesModel temporarily down on that provider
UNKNOWNYesUnknown — worth trying next provider
INVALID_KEYNoKey is wrong — failover won't fix it
CONTENT_FILTEREDNoContent policy violation — not a provider issue
QUOTA_EXHAUSTEDNoPlatform 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.

SSE response pattern (src/app/api/ai/generate/route.ts)
// 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",
    },
  });
}
typescript

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.

Prompt sanitizer usage
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;
}
typescript

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

FileResponsibility
src/lib/ai/router.tsMulti-provider failover router — buildCandidateList(), streamWithFailover()
src/lib/ai/gemini.tsSingleton Gemini client, SYSTEM_PROMPT, HUMANIZE_DIRECTIVE constant
src/lib/ai/client.tsBYOK multi-provider factory — streamGenerate()
src/lib/ai/stream-supervisor.tsMid-stream failure detection (idle timeout, max bytes, provider error)
src/lib/ai/prompt-sanitizer.tsInput sanitizer — injection signal stripping
src/lib/ai/output-validator.tsOutput validator — system prompt leak detection
src/lib/ai/rate-limit.tsPer-tenant sliding-window rate limiter
src/app/api/ai/generate/route.tsSSE streaming endpoint