- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
488 lines
22 KiB
TypeScript
488 lines
22 KiB
TypeScript
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<ReturnType<typeof storage.getLlmProvider>>) {
|
||
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<string, string> = {};
|
||
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<string, unknown> = {};
|
||
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<string, string> = {};
|
||
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) {
|
||
return res.json({ success: false, error: `Ошибка получения моделей: ${err?.message ?? err}` });
|
||
}
|
||
}
|
||
);
|
||
|
||
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: `Ошибка загрузки модели: ${err?.message ?? err}` });
|
||
}
|
||
try {
|
||
res.write(JSON.stringify({ error: `Ошибка загрузки: ${err?.message ?? err}` }) + "\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: `Ошибка удаления модели: ${err?.message ?? err}` });
|
||
}
|
||
}
|
||
);
|
||
|
||
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) {
|
||
return res.json({ success: false, error: `Ошибка подключения к Ollama: ${err?.message ?? err}` });
|
||
}
|
||
}
|
||
|
||
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) {
|
||
return res.json({ success: false, error: `Ошибка подключения: ${err?.message ?? err}` });
|
||
}
|
||
} catch (err: any) {
|
||
console.error("[LLM-Providers] test error:", err);
|
||
return res.json({ success: false, error: `Внутренняя ошибка: ${err?.message ?? err}` });
|
||
}
|
||
}
|
||
);
|
||
|
||
export default router;
|