UNPKG

@juspay/neurolink

Version:

Universal AI Development Platform with working MCP integration, multi-provider support, voice (TTS/STT/realtime), and professional CLI. 58+ external MCP servers discoverable, multimodal file processing, RAG pipelines. Build, test, and deploy AI applicatio

540 lines (539 loc) 21.2 kB
/** * YAML/JSON proxy configuration loader with environment variable resolution. * * Supports: * - Loading config from YAML or JSON files * - Environment variable interpolation: ${VAR_NAME} and ${VAR_NAME:-default} * - Multi-account proxy configurations * - Defaults for optional fields * * YAML parsing uses `js-yaml` when available (dynamic import), otherwise * falls back to JSON.parse. */ import { readFile } from "node:fs/promises"; import { extname } from "node:path"; import { logger } from "../utils/logger.js"; // --------------------------------------------------------------------------- // Environment variable resolution // --------------------------------------------------------------------------- /** * Regex matching `${VAR}` and `${VAR:-default}` patterns. * Non-greedy to handle multiple vars on a single line. */ const ENV_VAR_PATTERN = /\$\{([^}:]+?)(?::-(.*?))?\}/g; /** * Replace all `${VAR}` / `${VAR:-default}` references in a string. * * Resolution order: * 1. Look up `VAR` in the provided env map (or process.env) * 2. If not found, use the `:-default` value when present * 3. If no default, leave the original `${VAR}` token so callers can detect * unresolved variables. */ export function resolveEnvVars(value, env = process.env) { return value.replace(ENV_VAR_PATTERN, (_match, varName, defaultValue) => { const envValue = env[varName]; if (envValue !== undefined) { return envValue; } if (defaultValue !== undefined) { return defaultValue; } // Leave unresolved — callers can detect this return `\${${varName}}`; }); } /** * Recursively walk an object tree and resolve env vars in every string value. */ function resolveEnvVarsDeep(obj, env) { if (typeof obj === "string") { return resolveEnvVars(obj, env); } if (Array.isArray(obj)) { return obj.map((item) => resolveEnvVarsDeep(item, env)); } if (obj !== null && typeof obj === "object") { const result = {}; for (const [key, val] of Object.entries(obj)) { result[key] = resolveEnvVarsDeep(val, env); } return result; } return obj; } /** * Walk the resolved config tree and warn about any remaining `${VAR}` * placeholders that were not resolved. Returns the list of unresolved * variable names so callers can decide whether to abort. */ function warnUnresolvedPlaceholders(obj, path = "") { const unresolved = []; if (typeof obj === "string") { // Reset global regex state before matching ENV_VAR_PATTERN.lastIndex = 0; let match = ENV_VAR_PATTERN.exec(obj); while (match !== null) { const varName = match[1]; unresolved.push(varName); logger.warn(`Unresolved placeholder \${${varName}} at "${path}" — ` + "check that the environment variable is set or provide a default with ${VAR:-default}"); match = ENV_VAR_PATTERN.exec(obj); } } else if (Array.isArray(obj)) { for (let i = 0; i < obj.length; i++) { unresolved.push(...warnUnresolvedPlaceholders(obj[i], `${path}[${i}]`)); } } else if (obj !== null && typeof obj === "object") { for (const [key, val] of Object.entries(obj)) { unresolved.push(...warnUnresolvedPlaceholders(val, path ? `${path}.${key}` : key)); } } return unresolved; } /** * Check for unresolved `${VAR}` placeholders in critical account fields * (apiKey, token, key) and throw if any are found. Non-critical unresolved * placeholders are allowed (they only produce warnings). */ function failOnUnresolvedAccountCredentials(obj) { if (obj === null || typeof obj !== "object" || Array.isArray(obj)) { return; } const raw = obj; const accounts = raw.accounts; if (!accounts || typeof accounts !== "object" || Array.isArray(accounts)) { return; } const criticalFields = ["apiKey", "token", "key"]; const failures = []; for (const [provider, list] of Object.entries(accounts)) { if (!Array.isArray(list)) { continue; } for (let i = 0; i < list.length; i++) { const acct = list[i]; if (!acct || typeof acct !== "object") { continue; } for (const field of criticalFields) { const val = acct[field]; if (typeof val === "string") { ENV_VAR_PATTERN.lastIndex = 0; if (ENV_VAR_PATTERN.test(val)) { ENV_VAR_PATTERN.lastIndex = 0; failures.push(`accounts.${provider}[${i}].${field}`); } ENV_VAR_PATTERN.lastIndex = 0; } } } } if (failures.length > 0) { throw new Error(`Unresolved environment variable placeholders in critical account fields:\n - ${failures.join("\n - ")}\n` + "Set the required environment variables or provide defaults with ${VAR:-default}."); } } // --------------------------------------------------------------------------- // YAML parsing (dynamic import with fallback) // --------------------------------------------------------------------------- /** Shape of the dynamically-imported `js-yaml` module. */ /** * Parse YAML content into a JS object. * Uses `js-yaml` if available (dynamic import), otherwise falls back to * JSON.parse. */ async function parseYaml(content) { let yaml; try { yaml = (await import(/* @vite-ignore */ "js-yaml")); } catch { // js-yaml not installed — try JSON fallback logger.debug("[ProxyConfig] js-yaml not available, falling back to JSON parser"); try { return JSON.parse(content); } catch { throw new Error("Failed to parse proxy config: js-yaml is not installed and the file is not valid JSON"); } } // js-yaml is available — parse YAML (let syntax errors propagate) try { return yaml.default?.load?.(content) ?? yaml.load(content); } catch (err) { throw new Error(`Failed to parse proxy config as YAML: ${err instanceof Error ? err.message : String(err)}`, { cause: err }); } } // --------------------------------------------------------------------------- // Defaults & validation // --------------------------------------------------------------------------- const DEFAULT_WEIGHT = 1; const DEFAULT_ENABLED = true; /** * Apply default values to an account config. */ function applyAccountDefaults(account) { return { name: account.name ?? "unnamed", apiKey: account.apiKey ?? "", baseUrl: account.baseUrl, orgId: account.orgId, weight: account.weight ?? DEFAULT_WEIGHT, enabled: account.enabled ?? DEFAULT_ENABLED, rateLimit: account.rateLimit, metadata: account.metadata, }; } /** * Validate the shape of a parsed proxy config. * Returns an array of human-readable error strings (empty = valid). */ export function validateProxyConfig(config) { const errors = []; if (!config || typeof config !== "object") { errors.push("Config must be a non-null object"); return errors; } const cfg = config; if (cfg.version !== undefined && typeof cfg.version !== "number") { errors.push(`"version" must be a number, got ${typeof cfg.version}`); } const hasAccounts = !!cfg.accounts && typeof cfg.accounts === "object" && !Array.isArray(cfg.accounts); const hasRouting = !!cfg.routing && typeof cfg.routing === "object" && !Array.isArray(cfg.routing); if (cfg.routing !== undefined && !hasRouting) { errors.push('"routing" must be an object'); return errors; } if (!hasAccounts && !hasRouting) { errors.push('Config must contain at least one of "accounts" or "routing"'); return errors; } if (cfg.accounts !== undefined && !hasAccounts) { errors.push('"accounts" must be an object mapping provider names to account arrays'); return errors; } if (hasAccounts) { const accounts = cfg.accounts; let totalAccounts = 0; for (const [provider, list] of Object.entries(accounts)) { if (!Array.isArray(list)) { errors.push(`accounts.${provider} must be an array, got ${typeof list}`); continue; } totalAccounts += list.length; for (let i = 0; i < list.length; i++) { const acct = list[i]; if (!acct || typeof acct !== "object") { errors.push(`accounts.${provider}[${i}] must be an object`); continue; } if (typeof acct.apiKey !== "string" || acct.apiKey.length === 0) { errors.push(`accounts.${provider}[${i}].apiKey is required and must be a non-empty string`); } } } if (totalAccounts === 0 && !hasRouting) { errors.push('"accounts" must contain at least one account'); } } return errors; } // --------------------------------------------------------------------------- // Plaintext key detection // --------------------------------------------------------------------------- /** * Detect API keys stored as plaintext (not using `${ENV_VAR}` references) * and log a warning for each account. */ function warnPlaintextApiKeys(accounts) { for (const [provider, list] of Object.entries(accounts)) { for (const acct of list) { if (acct.apiKey && acct.apiKey.length > 0 && !ENV_VAR_PATTERN.test(acct.apiKey)) { // Reset the regex lastIndex (it has the global flag) ENV_VAR_PATTERN.lastIndex = 0; logger.warn(`\u26A0 API key stored in plaintext in config file for ${provider}/${acct.name}. ` + "Consider using ${ENV_VAR} references."); } // Also reset after a non-match test ENV_VAR_PATTERN.lastIndex = 0; } } } // --------------------------------------------------------------------------- // Routing config parser // --------------------------------------------------------------------------- /** * Parse the optional `routing` section from a raw proxy config object. * * Extracts: * - `strategy` ("round-robin" | "fill-first") * - `model-mappings` / `modelMappings` — array of {from, to, provider} * - `fallback-chain` / `fallbackChain` — array of {provider, model} * - `passthroughModels` / `passthrough-models` — array of model IDs * * Accepts both camelCase and kebab-case keys for YAML-friendliness. */ function parseRoutingConfig(raw) { if (!raw || typeof raw !== "object") { return undefined; } const result = {}; // Strategy const strategy = raw.strategy; if (strategy === "round-robin" || strategy === "fill-first") { result.strategy = strategy; } // Model mappings (accept kebab-case or camelCase) const rawMappings = (raw["model-mappings"] ?? raw.modelMappings); if (Array.isArray(rawMappings)) { result.modelMappings = rawMappings .filter((m) => m !== null && typeof m === "object") .map((m) => { const from = String(m.from ?? "").trim(); const to = String(m.to ?? "").trim(); const provider = String(m.provider ?? "anthropic").trim() || "anthropic"; if (!from || !to) { logger.warn(`[proxy-config] Skipping model mapping with empty "from" or "to": ${JSON.stringify(m)}`); return null; } return { from, to, provider, }; }) .filter((m) => m !== null); } // Fallback chain (accept kebab-case or camelCase) const rawFallback = (raw["fallback-chain"] ?? raw.fallbackChain); if (Array.isArray(rawFallback)) { result.fallbackChain = rawFallback .filter((e) => e !== null && typeof e === "object") .map((e) => { const provider = String(e.provider ?? "").trim(); const model = String(e.model ?? "").trim(); if (!provider || !model) { logger.warn(`[proxy-config] Skipping fallback entry with empty "provider" or "model": ${JSON.stringify(e)}`); return null; } return { provider, model }; }) .filter((e) => e !== null); } // Passthrough models (accept kebab-case or camelCase) const rawPassthrough = (raw["passthrough-models"] ?? raw.passthroughModels); if (Array.isArray(rawPassthrough)) { result.passthroughModels = rawPassthrough.map(String); } // Primary account (accept kebab-case or camelCase). Email or label of the // Anthropic account that should be tried first ("home"). Resolved to a // stable key (anthropic:<email>) at proxy boot; absence preserves the // pre-existing insertion-order behavior. const rawPrimary = (raw["primary-account"] ?? raw.primaryAccount); if (rawPrimary !== undefined) { if (typeof rawPrimary === "string" && rawPrimary.trim() !== "") { result.primaryAccount = rawPrimary.trim(); } else { logger.warn(`[proxy-config] Ignoring routing.primaryAccount: expected non-empty ` + `string, got ${typeof rawPrimary}`); } } return result; } // --------------------------------------------------------------------------- // Cloaking config validation // --------------------------------------------------------------------------- const VALID_CLOAKING_MODES = new Set(["auto", "always", "never"]); /** * Validate and return a CloakingConfig, or `undefined` if the section is absent. * Throws on structurally invalid input so problems surface at config-load time * rather than at first proxy request. */ function validateCloakingConfig(raw) { if (raw === undefined || raw === null) { return undefined; } if (typeof raw !== "object" || Array.isArray(raw)) { throw new Error(`Invalid proxy config: "cloaking" must be an object, got ${typeof raw}`); } const obj = raw; if (!VALID_CLOAKING_MODES.has(obj.mode)) { throw new Error(`Invalid proxy config: "cloaking.mode" must be one of "auto", "always", "never", got "${String(obj.mode)}"`); } if (obj.plugins !== undefined && (typeof obj.plugins !== "object" || obj.plugins === null || Array.isArray(obj.plugins))) { throw new Error(`Invalid proxy config: "cloaking.plugins" must be an object, got ${typeof obj.plugins}`); } return raw; } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- /** * Load and parse a proxy configuration file (YAML or JSON). * * @param filePath - Absolute or relative path to the config file. * @param options - Optional settings for env resolution. * @returns Parsed and validated ProxyConfigFile. * @throws When the file cannot be read, parsed, or fails validation. */ export async function loadProxyConfig(filePath, options = {}) { const { resolveEnv: shouldResolve = true, env = process.env } = options; logger.debug("[ProxyConfig] Loading proxy config", { filePath }); // 1. Read file let content; try { content = await readFile(filePath, "utf-8"); } catch (err) { throw new Error(`Failed to read proxy config file: ${filePath}${err.message}`, { cause: err }); } // 2. Parse (YAML for .yml/.yaml, JSON for .json, try YAML-then-JSON for others) const ext = extname(filePath).toLowerCase(); let parsed; if (ext === ".json") { try { parsed = JSON.parse(content); } catch (err) { throw new Error(`Failed to parse JSON proxy config: ${err.message}`, { cause: err }); } } else { // .yml, .yaml, or unknown — try YAML (which also handles JSON) parsed = await parseYaml(content); } // 3. Warn about plaintext API keys BEFORE env-var resolution so that // correctly parameterized `${ENV_VAR}` configs are not false-positived. // Guard each entry: only process arrays (non-arrays are caught by // validateProxyConfig below, so we must not crash here first). { const preResolvedRaw = parsed; const preResolvedAccounts = preResolvedRaw.accounts; if (preResolvedAccounts && typeof preResolvedAccounts === "object" && !Array.isArray(preResolvedAccounts)) { const preAccounts = {}; for (const [provider, list] of Object.entries(preResolvedAccounts)) { if (!Array.isArray(list)) { continue; } preAccounts[provider] = list.map((item) => applyAccountDefaults(item)); } warnPlaintextApiKeys(preAccounts); } } // 4. Resolve env vars if (shouldResolve) { parsed = resolveEnvVarsDeep(parsed, env); // 4b. Warn about any placeholders that could not be resolved warnUnresolvedPlaceholders(parsed); // 4c. Fail hard if critical credential fields still have unresolved vars failOnUnresolvedAccountCredentials(parsed); } // 5. Validate const errors = validateProxyConfig(parsed); if (errors.length > 0) { throw new Error(`Invalid proxy config:\n - ${errors.join("\n - ")}`); } // 6. Apply defaults const raw = parsed; const accounts = {}; const rawAccounts = raw.accounts; if (rawAccounts && typeof rawAccounts === "object" && !Array.isArray(rawAccounts)) { for (const [provider, list] of Object.entries(rawAccounts)) { accounts[provider] = list.map((item) => applyAccountDefaults(item)); } } // 7. Extract routing config const routing = parseRoutingConfig(raw.routing); // 8. Extract and validate cloaking config const cloaking = validateCloakingConfig(raw.cloaking); const result = { version: raw.version ?? 1, defaultProvider: raw.defaultProvider, defaultBaseUrl: raw.defaultBaseUrl, accounts, routing, cloaking, }; logger.debug("[ProxyConfig] Proxy config loaded successfully", { providers: Object.keys(accounts), totalAccounts: Object.values(accounts).reduce((sum, a) => sum + a.length, 0), hasRouting: !!routing, hasCloaking: !!cloaking, }); return result; } /** * Load proxy config from a raw string (YAML or JSON) instead of a file path. * Useful for testing or when config is stored in environment variables. */ export async function parseProxyConfigString(content, options = {}) { const { resolveEnv: shouldResolve = true, env = process.env } = options; let parsed = await parseYaml(content); // Warn about plaintext API keys BEFORE env-var resolution (same as loadProxyConfig). { const preResolvedRaw = parsed; const preResolvedAccounts = preResolvedRaw.accounts; if (preResolvedAccounts && typeof preResolvedAccounts === "object" && !Array.isArray(preResolvedAccounts)) { const preAccounts = {}; for (const [provider, list] of Object.entries(preResolvedAccounts)) { if (!Array.isArray(list)) { continue; } preAccounts[provider] = list.map((item) => applyAccountDefaults(item)); } warnPlaintextApiKeys(preAccounts); } } if (shouldResolve) { parsed = resolveEnvVarsDeep(parsed, env); // Warn about any placeholders that could not be resolved warnUnresolvedPlaceholders(parsed); // Fail hard if critical credential fields still have unresolved vars failOnUnresolvedAccountCredentials(parsed); } const errors = validateProxyConfig(parsed); if (errors.length > 0) { throw new Error(`Invalid proxy config:\n - ${errors.join("\n - ")}`); } const raw = parsed; const accounts = {}; const rawAccounts = raw.accounts; if (rawAccounts && typeof rawAccounts === "object" && !Array.isArray(rawAccounts)) { for (const [provider, list] of Object.entries(rawAccounts)) { accounts[provider] = list.map((item) => applyAccountDefaults(item)); } } const routing = parseRoutingConfig(raw.routing); const cloaking = validateCloakingConfig(raw.cloaking); return { version: raw.version ?? 1, defaultProvider: raw.defaultProvider, defaultBaseUrl: raw.defaultBaseUrl, accounts, routing, cloaking, }; }