/** * Server-side Provider Configuration * * Loads provider configs from YAML (primary) + environment variables (fallback). * Keys never leave the server — only provider IDs and metadata are exposed via API. */ import fs from 'fs'; import path from 'path'; import yaml from 'js-yaml'; import { createLogger } from '@/lib/logger'; const log = createLogger('ServerProviderConfig'); // --------------------------------------------------------------------------- // Types // --------------------------------------------------------------------------- interface ServerProviderEntry { apiKey: string; baseUrl?: string; models?: string[]; proxy?: string; } interface ServerConfig { providers: Record; tts: Record; asr: Record; pdf: Record; image: Record; video: Record; webSearch: Record; } // --------------------------------------------------------------------------- // Env-var prefix mappings // --------------------------------------------------------------------------- const LLM_ENV_MAP: Record = { OPENAI: 'openai', ANTHROPIC: 'anthropic', GOOGLE: 'google', DEEPSEEK: 'deepseek', QWEN: 'qwen', KIMI: 'kimi', MINIMAX: 'minimax', GLM: 'glm', SILICONFLOW: 'siliconflow', DOUBAO: 'doubao', OPENROUTER: 'openrouter', GROK: 'grok', TENCENT: 'tencent-hunyuan', TENCENT_HUNYUAN: 'tencent-hunyuan', XIAOMI: 'xiaomi', MIMO: 'xiaomi', OLLAMA: 'ollama', }; const TTS_ENV_MAP: Record = { TTS_OPENAI: 'openai-tts', TTS_AZURE: 'azure-tts', TTS_GLM: 'glm-tts', TTS_QWEN: 'qwen-tts', TTS_VOXCPM: 'voxcpm-tts', TTS_DOUBAO: 'doubao-tts', TTS_ELEVENLABS: 'elevenlabs-tts', TTS_MINIMAX: 'minimax-tts', }; const ASR_ENV_MAP: Record = { ASR_OPENAI: 'openai-whisper', ASR_QWEN: 'qwen-asr', }; const PDF_ENV_MAP: Record = { PDF_UNPDF: 'unpdf', PDF_MINERU: 'mineru', PDF_MINERU_CLOUD: 'mineru-cloud', }; const IMAGE_ENV_MAP: Record = { IMAGE_OPENAI: 'openai-image', IMAGE_SEEDREAM: 'seedream', IMAGE_QWEN_IMAGE: 'qwen-image', IMAGE_NANO_BANANA: 'nano-banana', IMAGE_MINIMAX: 'minimax-image', IMAGE_GROK: 'grok-image', }; const VIDEO_ENV_MAP: Record = { VIDEO_SEEDANCE: 'seedance', VIDEO_KLING: 'kling', VIDEO_VEO: 'veo', VIDEO_SORA: 'sora', VIDEO_MINIMAX: 'minimax-video', VIDEO_GROK: 'grok-video', }; const WEB_SEARCH_ENV_MAP: Record = { TAVILY: 'tavily', }; // --------------------------------------------------------------------------- // YAML loading // --------------------------------------------------------------------------- type YamlData = Partial<{ providers: Record>; tts: Record>; asr: Record>; pdf: Record>; image: Record>; video: Record>; 'web-search': Record>; }>; function loadYamlFile(filename: string): YamlData { try { const filePath = path.join(process.cwd(), filename); if (!fs.existsSync(filePath)) return {}; const raw = fs.readFileSync(filePath, 'utf-8'); const parsed = yaml.load(raw) as Record | null; if (!parsed || typeof parsed !== 'object') return {}; return parsed as YamlData; } catch (e) { log.warn(`[ServerProviderConfig] Failed to load ${filename}:`, e); return {}; } } // --------------------------------------------------------------------------- // Env-var helpers // --------------------------------------------------------------------------- function loadEnvSection( envMap: Record, yamlSection: Record> | undefined, { requiresBaseUrl = false, keylessProviders = new Set(), }: { requiresBaseUrl?: boolean; keylessProviders?: Set } = {}, ): Record { const result: Record = {}; // First, add everything from YAML as defaults if (yamlSection) { for (const [id, entry] of Object.entries(yamlSection)) { if ( requiresBaseUrl ? !!entry?.baseUrl : entry?.apiKey || (entry?.baseUrl && keylessProviders.has(id)) ) { result[id] = { apiKey: entry.apiKey || '', baseUrl: entry.baseUrl, models: entry.models, proxy: entry.proxy, }; } } } // Then, apply env vars (env takes priority over YAML) for (const [prefix, providerId] of Object.entries(envMap)) { const envApiKey = process.env[`${prefix}_API_KEY`] || undefined; const envBaseUrl = process.env[`${prefix}_BASE_URL`] || undefined; const envModelsStr = process.env[`${prefix}_MODELS`]; const envModels = envModelsStr ? envModelsStr .split(',') .map((m) => m.trim()) .filter(Boolean) : undefined; if (result[providerId]) { // YAML entry exists — env vars override individual fields if (envApiKey) result[providerId].apiKey = envApiKey; if (envBaseUrl) result[providerId].baseUrl = envBaseUrl; if (envModels) result[providerId].models = envModels; continue; } // Activate on API key, or base URL alone for keyless providers (e.g. Ollama) if ( requiresBaseUrl ? !envBaseUrl : !(envApiKey || (envBaseUrl && keylessProviders.has(providerId))) ) continue; result[providerId] = { apiKey: envApiKey || '', baseUrl: envBaseUrl, models: envModels, }; } return result; } // --------------------------------------------------------------------------- // Module-level cache (process singleton) // --------------------------------------------------------------------------- const DEFAULT_FILENAME = 'server-providers.yml'; /** Cache keyed by YAML filename (empty string = default file). */ const _configs: Map = new Map(); function buildConfig(yamlData: YamlData): ServerConfig { return { providers: loadEnvSection(LLM_ENV_MAP, yamlData.providers, { keylessProviders: new Set(['ollama']), }), tts: loadEnvSection(TTS_ENV_MAP, yamlData.tts, { keylessProviders: new Set(['voxcpm-tts']), }), asr: loadEnvSection(ASR_ENV_MAP, yamlData.asr), pdf: loadEnvSection(PDF_ENV_MAP, yamlData.pdf, { requiresBaseUrl: true }), image: loadEnvSection(IMAGE_ENV_MAP, yamlData.image), video: loadEnvSection(VIDEO_ENV_MAP, yamlData.video), webSearch: loadEnvSection(WEB_SEARCH_ENV_MAP, yamlData['web-search']), }; } function logConfig(config: ServerConfig, label: string): void { const counts = [ Object.keys(config.providers).length, Object.keys(config.tts).length, Object.keys(config.asr).length, Object.keys(config.pdf).length, Object.keys(config.image).length, Object.keys(config.video).length, Object.keys(config.webSearch).length, ]; if (counts.some((c) => c > 0)) { log.info( `[ServerProviderConfig] Loaded (${label}): ${counts[0]} LLM, ${counts[1]} TTS, ${counts[2]} ASR, ${counts[3]} PDF, ${counts[4]} Image, ${counts[5]} Video, ${counts[6]} WebSearch providers`, ); } } function getConfig(): ServerConfig { const cached = _configs.get(''); if (cached) return cached; const yamlData = loadYamlFile(DEFAULT_FILENAME); const config = buildConfig(yamlData); logConfig(config, DEFAULT_FILENAME); _configs.set('', config); return config; } // --------------------------------------------------------------------------- // Public API — LLM // --------------------------------------------------------------------------- /** Returns server-configured LLM providers (no apiKeys) */ export function getServerProviders(): Record { const cfg = getConfig(); const result: Record = {}; for (const [id, entry] of Object.entries(cfg.providers)) { result[id] = {}; if (entry.models && entry.models.length > 0) result[id].models = entry.models; if (entry.baseUrl) result[id].baseUrl = entry.baseUrl; } return result; } /** Resolve API key: client key > server key > empty string */ export function resolveApiKey(providerId: string, clientKey?: string): string { if (clientKey) return clientKey; return getConfig().providers[providerId]?.apiKey || ''; } /** Resolve base URL: client > server > undefined */ export function resolveBaseUrl(providerId: string, clientBaseUrl?: string): string | undefined { if (clientBaseUrl) return clientBaseUrl; return getConfig().providers[providerId]?.baseUrl; } /** Resolve proxy URL for a provider (server config only) */ export function resolveProxy(providerId: string): string | undefined { return getConfig().providers[providerId]?.proxy; } // --------------------------------------------------------------------------- // Public API — TTS // --------------------------------------------------------------------------- export function getServerTTSProviders(): Record { const cfg = getConfig(); const result: Record = {}; for (const [id, entry] of Object.entries(cfg.tts)) { result[id] = {}; if (entry.baseUrl) result[id].baseUrl = entry.baseUrl; } return result; } export function resolveTTSApiKey(providerId: string, clientKey?: string): string { if (clientKey) return clientKey; return getConfig().tts[providerId]?.apiKey || ''; } export function resolveTTSBaseUrl(providerId: string, clientBaseUrl?: string): string | undefined { if (clientBaseUrl) return clientBaseUrl; return getConfig().tts[providerId]?.baseUrl; } // --------------------------------------------------------------------------- // Public API — ASR // --------------------------------------------------------------------------- export function getServerASRProviders(): Record { const cfg = getConfig(); const result: Record = {}; for (const [id, entry] of Object.entries(cfg.asr)) { result[id] = {}; if (entry.baseUrl) result[id].baseUrl = entry.baseUrl; } return result; } export function resolveASRApiKey(providerId: string, clientKey?: string): string { if (clientKey) return clientKey; return getConfig().asr[providerId]?.apiKey || ''; } export function resolveASRBaseUrl(providerId: string, clientBaseUrl?: string): string | undefined { if (clientBaseUrl) return clientBaseUrl; return getConfig().asr[providerId]?.baseUrl; } // --------------------------------------------------------------------------- // Public API — PDF // --------------------------------------------------------------------------- export function getServerPDFProviders(): Record { const cfg = getConfig(); const result: Record = {}; for (const [id, entry] of Object.entries(cfg.pdf)) { result[id] = {}; if (entry.baseUrl) result[id].baseUrl = entry.baseUrl; } return result; } export function resolvePDFApiKey(providerId: string, clientKey?: string): string { if (clientKey) return clientKey; return getConfig().pdf[providerId]?.apiKey || ''; } export function resolvePDFBaseUrl(providerId: string, clientBaseUrl?: string): string | undefined { if (clientBaseUrl) return clientBaseUrl; return getConfig().pdf[providerId]?.baseUrl; } // --------------------------------------------------------------------------- // Public API — Image Generation // --------------------------------------------------------------------------- export function getServerImageProviders(): Record { const cfg = getConfig(); const result: Record = {}; for (const [id, entry] of Object.entries(cfg.image)) { result[id] = {}; if (entry.baseUrl) result[id].baseUrl = entry.baseUrl; } return result; } export function resolveImageApiKey(providerId: string, clientKey?: string): string { if (clientKey) return clientKey; return getConfig().image[providerId]?.apiKey || ''; } export function resolveImageBaseUrl( providerId: string, clientBaseUrl?: string, ): string | undefined { if (clientBaseUrl) return clientBaseUrl; return getConfig().image[providerId]?.baseUrl; } // --------------------------------------------------------------------------- // Public API — Video Generation // --------------------------------------------------------------------------- export function getServerVideoProviders(): Record { const cfg = getConfig(); const result: Record = {}; for (const [id, entry] of Object.entries(cfg.video)) { result[id] = {}; if (entry.baseUrl) result[id].baseUrl = entry.baseUrl; } return result; } export function resolveVideoApiKey(providerId: string, clientKey?: string): string { if (clientKey) return clientKey; return getConfig().video[providerId]?.apiKey || ''; } export function resolveVideoBaseUrl( providerId: string, clientBaseUrl?: string, ): string | undefined { if (clientBaseUrl) return clientBaseUrl; return getConfig().video[providerId]?.baseUrl; } // --------------------------------------------------------------------------- // Public API — Web Search (Tavily) // --------------------------------------------------------------------------- /** Returns server-configured web search providers (no apiKeys exposed) */ export function getServerWebSearchProviders(): Record { const cfg = getConfig(); const result: Record = {}; for (const [id, entry] of Object.entries(cfg.webSearch)) { result[id] = {}; if (entry.baseUrl) result[id].baseUrl = entry.baseUrl; } return result; } /** Resolve Tavily API key: client key > server key > TAVILY_API_KEY env > empty */ export function resolveWebSearchApiKey(clientKey?: string): string { if (clientKey) return clientKey; const serverKey = getConfig().webSearch.tavily?.apiKey; if (serverKey) return serverKey; return process.env.TAVILY_API_KEY || ''; }