Files
iistwin/server/routes/llm-providers.routes.ts
Ильяс Султанов 1f5ecb6da4 fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
2026-07-07 21:03:40 +03:00

488 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;