import { Router } from "express"; import { storage } from "../storage"; import { authenticateToken, requirePermission, type AuthenticatedRequest } from "../middleware/auth.middleware"; import { tenantIsolation } from "../middleware/tenant.middleware"; import { encrypt, decrypt } from "../crypto"; const router = Router(); function maskApiKey(key: string): string { if (!key || key.length <= 4) return "****"; return `***${key.slice(-4)}`; } function validateBaseUrl(raw: string): { ok: true; url: string } | { ok: false; error: string } { let parsed: URL; try { parsed = new URL(raw.trim()); } catch { return { ok: false, error: "Некорректный URL (неверный формат)" }; } if (!["http:", "https:"].includes(parsed.protocol)) { return { ok: false, error: "URL должен начинаться с http:// или https://" }; } // Normalize: remove trailing slash but preserve any path prefix (important for openai_compatible proxies) const normalizedUrl = parsed.href.replace(/\/+$/, ""); if (process.env.ALLOW_PRIVATE_RAG_URLS === "true") { return { ok: true, url: normalizedUrl }; } const host = parsed.hostname.toLowerCase(); const privatePatterns = [ /^localhost$/, /^127\./, /^0\.0\.0\.0$/, /^::1$/, /^10\./, /^172\.(1[6-9]|2\d|3[01])\./, /^192\.168\./, /^169\.254\./, /^fc00:/i, /^fd[0-9a-f]{2}:/i, /^fe80:/i, ]; for (const re of privatePatterns) { if (re.test(host)) { return { ok: false, error: `Частные/локальные адреса запрещены (${host}). Установите ALLOW_PRIVATE_RAG_URLS=true для локального Ollama.`, }; } } return { ok: true, url: normalizedUrl }; } function formatProvider(p: Awaited>) { if (!p) return null; return { id: p.id, organizationId: p.organizationId, name: p.name, providerType: p.providerType, baseUrl: p.baseUrl, apiKeyMasked: p.apiKey ? maskApiKey(decrypt(p.apiKey)) : null, enabledModels: p.enabledModels ?? [], customHeaders: p.customHeaders ?? {}, isActive: p.isActive, createdAt: p.createdAt, updatedAt: p.updatedAt, }; } // Admin-only: llm-providers management is intentionally restricted to admins. // All consumers (bot create/edit, RAG settings) are also admin-only routes, // so there is no role mismatch for authenticated non-admin users. router.get( "/api/llm-providers", authenticateToken, requirePermission('settings.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const providers = await storage.getLlmProviders(req.organizationId!); return res.json({ success: true, providers: providers.map(formatProvider) }); } catch (err) { console.error("[LLM-Providers] GET list error:", err); return res.status(500).json({ success: false, error: "Ошибка получения провайдеров" }); } } ); router.post( "/api/llm-providers", authenticateToken, requirePermission('settings.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const { name, providerType, baseUrl, apiKey, enabledModels, customHeaders, isActive } = req.body; if (!name || !name.trim()) { return res.status(400).json({ success: false, error: "Название обязательно" }); } const allowed = ["openai", "openai_compatible", "ollama"]; if (!allowed.includes(providerType ?? "openai")) { return res.status(400).json({ success: false, error: "Недопустимый тип провайдера" }); } const requiresBaseUrl = providerType === "openai_compatible" || providerType === "ollama"; if (requiresBaseUrl && (!baseUrl || !baseUrl.trim())) { return res.status(400).json({ success: false, error: "URL сервера обязателен для этого типа провайдера" }); } let validatedBaseUrl: string | null = null; if (baseUrl && baseUrl.trim()) { const check = validateBaseUrl(baseUrl.trim()); if (!check.ok) return res.status(400).json({ success: false, error: check.error }); validatedBaseUrl = check.url; } let normalizedHeaders: Record = {}; if (customHeaders !== undefined) { if (typeof customHeaders !== 'object' || customHeaders === null || Array.isArray(customHeaders)) { return res.status(400).json({ success: false, error: "customHeaders должен быть объектом" }); } for (const [k, v] of Object.entries(customHeaders)) { if (typeof v !== 'string') return res.status(400).json({ success: false, error: "Значения customHeaders должны быть строками" }); normalizedHeaders[k] = v; } } let normalizedModels: string[] = []; if (enabledModels !== undefined) { if (!Array.isArray(enabledModels) || enabledModels.some((m: unknown) => typeof m !== 'string')) { return res.status(400).json({ success: false, error: "enabledModels должен быть массивом строк" }); } normalizedModels = (enabledModels as string[]).map(m => m.trim()).filter(m => m.length > 0); } // openai_compatible requires an explicit API key (no env fallback to prevent key leakage to arbitrary hosts) if ((providerType === "openai_compatible") && (!apiKey || !apiKey.trim())) { return res.status(400).json({ success: false, error: "API ключ обязателен для провайдера openai_compatible" }); } const encryptedApiKey = apiKey && apiKey.trim() ? encrypt(apiKey.trim()) : null; const provider = await storage.createLlmProvider({ organizationId: req.organizationId!, name: name.trim(), providerType: providerType ?? "openai", baseUrl: validatedBaseUrl, apiKey: encryptedApiKey, enabledModels: normalizedModels, customHeaders: normalizedHeaders, isActive: isActive ?? true, }); return res.status(201).json({ success: true, provider: formatProvider(provider) }); } catch (err) { console.error("[LLM-Providers] POST create error:", err); return res.status(500).json({ success: false, error: "Ошибка создания провайдера" }); } } ); router.patch( "/api/llm-providers/:id", authenticateToken, requirePermission('settings.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const id = parseInt(req.params.id, 10); if (isNaN(id)) return res.status(400).json({ success: false, error: "Некорректный id" }); const existing = await storage.getLlmProvider(id, req.organizationId!); if (!existing) return res.status(404).json({ success: false, error: "Провайдер не найден" }); const updates: Record = {}; if (req.body.name !== undefined) { const trimmedName = req.body.name.trim(); if (!trimmedName) return res.status(400).json({ success: false, error: "Название не может быть пустым" }); updates.name = trimmedName; } if (req.body.providerType !== undefined) { const allowed = ["openai", "openai_compatible", "ollama"]; if (!allowed.includes(req.body.providerType)) { return res.status(400).json({ success: false, error: "Недопустимый тип провайдера" }); } updates.providerType = req.body.providerType; } if (req.body.baseUrl !== undefined) { if (req.body.baseUrl && req.body.baseUrl.trim()) { const check = validateBaseUrl(req.body.baseUrl.trim()); if (!check.ok) return res.status(400).json({ success: false, error: check.error }); updates.baseUrl = check.url; } else { const effectiveType = (updates.providerType ?? existing.providerType) as string; if (effectiveType === "openai_compatible" || effectiveType === "ollama") { return res.status(400).json({ success: false, error: "URL сервера обязателен для этого типа провайдера" }); } updates.baseUrl = null; } } if (req.body.apiKey !== undefined) { updates.apiKey = req.body.apiKey && req.body.apiKey.trim() ? encrypt(req.body.apiKey.trim()) : null; } if (req.body.customHeaders !== undefined) { if (typeof req.body.customHeaders !== 'object' || req.body.customHeaders === null || Array.isArray(req.body.customHeaders)) { return res.status(400).json({ success: false, error: "customHeaders должен быть объектом" }); } const validated: Record = {}; for (const [k, v] of Object.entries(req.body.customHeaders)) { if (typeof v !== 'string') return res.status(400).json({ success: false, error: "Значения customHeaders должны быть строками" }); validated[k] = v; } updates.customHeaders = validated; } if (req.body.enabledModels !== undefined) { if (!Array.isArray(req.body.enabledModels) || req.body.enabledModels.some((m: unknown) => typeof m !== 'string')) { return res.status(400).json({ success: false, error: "enabledModels должен быть массивом строк" }); } updates.enabledModels = (req.body.enabledModels as string[]).map(m => m.trim()).filter(m => m.length > 0); } if (req.body.isActive !== undefined) updates.isActive = Boolean(req.body.isActive); // Enforce baseUrl requirement on effective state (providerType may change without baseUrl in payload) const effectiveType = (updates.providerType ?? existing.providerType) as string; const effectiveBaseUrl = (updates.baseUrl !== undefined ? updates.baseUrl : existing.baseUrl) as string | null; const requiresBaseUrl = effectiveType === "openai_compatible" || effectiveType === "ollama"; if (requiresBaseUrl && !effectiveBaseUrl) { return res.status(400).json({ success: false, error: "URL сервера обязателен для этого типа провайдера" }); } // openai_compatible requires an API key to prevent env-key leakage to arbitrary hosts if (effectiveType === "openai_compatible") { const effectiveApiKey = updates.apiKey !== undefined ? updates.apiKey : existing.apiKey; if (!effectiveApiKey) { return res.status(400).json({ success: false, error: "API ключ обязателен для провайдера openai_compatible" }); } } const updated = await storage.updateLlmProvider(id, req.organizationId!, updates); return res.json({ success: true, provider: formatProvider(updated) }); } catch (err) { console.error("[LLM-Providers] PATCH error:", err); return res.status(500).json({ success: false, error: "Ошибка обновления провайдера" }); } } ); router.delete( "/api/llm-providers/:id", authenticateToken, requirePermission('settings.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const id = parseInt(req.params.id, 10); if (isNaN(id)) return res.status(400).json({ success: false, error: "Некорректный id" }); const existing = await storage.getLlmProvider(id, req.organizationId!); if (!existing) return res.status(404).json({ success: false, error: "Провайдер не найден" }); await storage.deleteLlmProvider(id, req.organizationId!); return res.json({ success: true }); } catch (err) { console.error("[LLM-Providers] DELETE error:", err); return res.status(500).json({ success: false, error: "Ошибка удаления провайдера" }); } } ); // ── Ollama model management ─────────────────────────────────────────────────── async function resolveOllamaBase(providerId: number, organizationId: number): Promise<{ ok: true; base: string } | { ok: false; error: string }> { const provider = await storage.getLlmProvider(providerId, organizationId); if (!provider) return { ok: false, error: "Провайдер не найден" }; if (provider.providerType !== "ollama") return { ok: false, error: "Провайдер не является Ollama" }; const base = (provider.baseUrl ?? process.env.OLLAMA_BASE_URL ?? "http://localhost:11434").replace(/\/+$/, ""); return { ok: true, base }; } router.get( "/api/llm-providers/:id/ollama-models", authenticateToken, requirePermission('settings.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const id = parseInt(req.params.id, 10); if (isNaN(id)) return res.status(400).json({ success: false, error: "Некорректный id" }); const resolved = await resolveOllamaBase(id, req.organizationId!); if (!resolved.ok) return res.status(400).json({ success: false, error: resolved.error }); const tagsRes = await fetch(`${resolved.base}/api/tags`, { signal: AbortSignal.timeout(10000) }); if (!tagsRes.ok) return res.json({ success: false, error: `Ollama недоступен (${tagsRes.status})` }); const tagsData = await tagsRes.json() as { models?: Array<{ name: string; size: number; digest: string; modified_at: string }> }; return res.json({ success: true, models: tagsData.models ?? [] }); } catch (err: any) { console.error("[LLM-Providers] ollama models error:", err); return res.json({ success: false, error: "Не удалось получить список моделей Ollama" }); } } ); router.post( "/api/llm-providers/ollama/pull", authenticateToken, requirePermission('settings.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const { providerId, model } = req.body; if (!providerId || !model || typeof model !== "string" || !model.trim()) { return res.status(400).json({ success: false, error: "providerId и model обязательны" }); } const numId = Number(providerId); if (!Number.isInteger(numId) || numId <= 0) { return res.status(400).json({ success: false, error: "Некорректный providerId" }); } const resolved = await resolveOllamaBase(numId, req.organizationId!); if (!resolved.ok) return res.status(400).json({ success: false, error: resolved.error }); const pullRes = await fetch(`${resolved.base}/api/pull`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: model.trim(), stream: true }), signal: AbortSignal.timeout(3600000), }); if (!pullRes.ok) { const text = await pullRes.text().catch(() => ""); return res.status(502).json({ success: false, error: `Ошибка Ollama pull (${pullRes.status}): ${text.slice(0, 200)}` }); } if (!pullRes.body) { return res.status(502).json({ success: false, error: "Ollama не вернул тело ответа" }); } res.setHeader("Content-Type", "application/x-ndjson"); res.setHeader("Transfer-Encoding", "chunked"); res.setHeader("Cache-Control", "no-cache"); res.setHeader("X-Accel-Buffering", "no"); const reader = pullRes.body.getReader(); const decoder = new TextDecoder(); try { while (true) { const { done, value } = await reader.read(); if (done) break; res.write(decoder.decode(value, { stream: true })); } } finally { reader.releaseLock(); } res.end(); } catch (err: any) { console.error("[LLM-Providers] ollama pull error:", err); if (!res.headersSent) { return res.status(502).json({ success: false, error: "Ошибка загрузки модели" }); } try { res.write(JSON.stringify({ error: "Ошибка загрузки модели" }) + "\n"); } catch {} res.end(); } } ); router.delete( "/api/llm-providers/ollama/model", authenticateToken, requirePermission('settings.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const { providerId, model } = req.body; if (!providerId || !model || typeof model !== "string" || !model.trim()) { return res.status(400).json({ success: false, error: "providerId и model обязательны" }); } const numId = Number(providerId); if (!Number.isInteger(numId) || numId <= 0) { return res.status(400).json({ success: false, error: "Некорректный providerId" }); } const resolved = await resolveOllamaBase(numId, req.organizationId!); if (!resolved.ok) return res.status(400).json({ success: false, error: resolved.error }); const deleteRes = await fetch(`${resolved.base}/api/delete`, { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: model.trim() }), signal: AbortSignal.timeout(30000), }); if (!deleteRes.ok) { const text = await deleteRes.text().catch(() => ""); return res.json({ success: false, error: `Ошибка удаления модели (${deleteRes.status}): ${text.slice(0, 200)}` }); } return res.json({ success: true }); } catch (err: any) { console.error("[LLM-Providers] ollama delete model error:", err); return res.json({ success: false, error: "Не удалось удалить модель" }); } } ); router.post( "/api/llm-providers/test", authenticateToken, requirePermission('settings.manage'), tenantIsolation, async (req: AuthenticatedRequest, res) => { try { const { providerType, baseUrl, apiKey, providerId } = req.body; let resolvedApiKey: string | null; let resolvedType: string; let resolvedBaseUrl: string | null; if (providerId != null) { const numId = Number(providerId); if (!Number.isInteger(numId) || numId <= 0) { return res.json({ success: false, error: "Некорректный providerId" }); } const existing = await storage.getLlmProvider(numId, req.organizationId!); if (!existing) { return res.json({ success: false, error: "Провайдер не найден" }); } resolvedType = existing.providerType; resolvedBaseUrl = existing.baseUrl ?? null; const storedKey = existing.apiKey ? decrypt(existing.apiKey) : null; resolvedApiKey = storedKey || (existing.providerType === "openai" ? process.env.OPENAI_API_KEY ?? null : null); } else { resolvedType = providerType ?? "openai"; resolvedBaseUrl = baseUrl?.trim() || null; const callerKey = apiKey && apiKey.trim() ? apiKey.trim() : null; if (callerKey) { resolvedApiKey = callerKey; } else if (resolvedType === "openai") { // Fixed trusted destination — env key is safe to use resolvedApiKey = process.env.OPENAI_API_KEY ?? null; } else { // openai_compatible or ollama: caller-controlled destination — require explicit key resolvedApiKey = null; } } // openai_compatible requires a base URL — reject immediately if missing if (resolvedType === "openai_compatible" && !resolvedBaseUrl) { return res.json({ success: false, error: "URL сервера обязателен для провайдера openai_compatible" }); } let apiBase: string; if (resolvedType === "ollama") { const rawBase = resolvedBaseUrl ?? "http://localhost:11434"; const check = validateBaseUrl(rawBase); if (!check.ok) return res.json({ success: false, error: check.error }); apiBase = check.url; } else if (resolvedType === "openai_compatible") { const check = validateBaseUrl(resolvedBaseUrl!); if (!check.ok) return res.json({ success: false, error: check.error }); apiBase = check.url; } else { apiBase = "https://api.openai.com"; } if (resolvedType === "ollama") { try { const tagsRes = await fetch(`${apiBase}/api/tags`, { signal: AbortSignal.timeout(10000), }); if (!tagsRes.ok) { return res.json({ success: false, error: `Ollama недоступен (${tagsRes.status})` }); } const tagsData = await tagsRes.json() as { models?: Array<{ name: string }> }; const models = (tagsData.models ?? []).map((m) => m.name); return res.json({ success: true, models }); } catch (err: any) { console.error("[LLM-Providers] ollama test connection error:", err); return res.json({ success: false, error: "Не удалось подключиться к Ollama" }); } } if (!resolvedApiKey) { return res.json({ success: false, error: "API ключ не задан" }); } try { const modelsRes = await fetch(`${apiBase}/models`, { headers: { Authorization: `Bearer ${resolvedApiKey}` }, signal: AbortSignal.timeout(10000), }); if (!modelsRes.ok) { const text = await modelsRes.text(); return res.json({ success: false, error: `API вернул ошибку (${modelsRes.status}): ${text.slice(0, 200)}` }); } const modelsData = await modelsRes.json() as { data?: Array<{ id: string }> }; const models = (modelsData.data ?? []).map((m) => m.id).sort(); return res.json({ success: true, models }); } catch (err: any) { console.error("[LLM-Providers] provider test connection error:", err); return res.json({ success: false, error: "Не удалось подключиться к провайдеру" }); } } catch (err: any) { console.error("[LLM-Providers] test error:", err); return res.json({ success: false, error: "Внутренняя ошибка сервера" }); } } ); export default router;