AI Provider Configuration

10 min readUpdated 02 Oct 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. 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.

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_keyPlatform default provider; built-in model gemini-3.6-flash (editor and long-form)
Anthropic Claudeanthropicai_anthropic_keyBuilt-in models: claude-sonnet-5-5 (editor), claude-opus-4-5 (long-form)
OpenAI / compatibleopenaiai_openai_keyBuilt-in models: gpt-4o (editor and long-form); works with OpenAI-compatible endpoints (ai_openai_base_url)
Custom OpenAI-compatiblecustomai_openai_key + ai_openai_base_urlOllama, 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.

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