'use strict'; /** * assist.js — каркас AI-ассистента (чистая механика, без интеграции в server.js). * * Только встроенные модули Node.js (>= 18): http/https/fetch/AbortController. * Никаких внешних npm-зависимостей. * * Экспортируемые функции: * - validateInput(req) * - createRateLimiter(opts) * - createSemaphore(max) * - llmChat({...}) * - normalizeQuery(q) * - jsonError(res, status, code, message, headers) */ // ───────────────────────────────────────────────────────────────────────────── // Конфигурация из переменных окружения (с дефолтами) // ───────────────────────────────────────────────────────────────────────────── const ENV = { ASSIST_MAX_CONCURRENT: intEnv('ASSIST_MAX_CONCURRENT', 4), ASSIST_TOTAL_DEADLINE_MS: intEnv('ASSIST_TOTAL_DEADLINE_MS', 40000), ASSIST_LLM_TIMEOUT_MS: intEnv('ASSIST_LLM_TIMEOUT_MS', 25000), ASSIST_RATE_PER_WINDOW: intEnv('ASSIST_RATE_PER_WINDOW', 6), ASSIST_RATE_WINDOW_MS: intEnv('ASSIST_RATE_WINDOW_MS', 60000), ASSIST_REPLICAS: intEnv('ASSIST_REPLICAS', 1), ASSIST_MAX_BODY: intEnv('ASSIST_MAX_BODY', 2048), LLM_BASE_URL: process.env.LLM_BASE_URL || '', LLM_API_KEY: process.env.LLM_API_KEY || '', LLM_MODEL: process.env.LLM_MODEL || 'gpt-oss-120b', }; /** Читает целочисленную переменную окружения с фолбэком и нижней границей > 0. */ function intEnv(name, fallback) { const raw = process.env[name]; if (raw === undefined || raw === null || raw === '') return fallback; const n = Number.parseInt(raw, 10); if (!Number.isFinite(n)) return fallback; return n; } /** Маскирует API-ключ: показывает не более 4 символов, остальное `***`. */ function maskKey(key) { if (!key) return '(empty)'; if (key.length <= 4) return '***'; return key.slice(0, 4) + '***'; } // ───────────────────────────────────────────────────────────────────────────── // Чтение тела запроса с потоковым счётчиком байт // ───────────────────────────────────────────────────────────────────────────── /** * Читает поток (req) со счётчиком байт; при превышении maxBytes обрывает * чтение через req.destroy() и резолвится {ok:false, code:413}. * * @param {import('http').IncomingMessage} req * @param {{maxBytes:number}} opts * @returns {Promise<{ok:true, body:string} | {ok:false, code:number, message:string}>} */ function readBodyWithLimit(req, { maxBytes }) { return new Promise((resolve) => { const chunks = []; let total = 0; let settled = false; const done = (result) => { if (settled) return; settled = true; resolve(result); }; req.on('data', (chunk) => { if (settled) return; total += chunk.length; if (total > maxBytes) { // Превышен лимит в процессе чтения — обрываем поток. req.destroy(); done({ ok: false, code: 413, message: 'body too large' }); return; } chunks.push(chunk); }); req.on('end', () => { done({ ok: true, body: Buffer.concat(chunks).toString('utf8') }); }); req.on('error', () => { // На уже завершённом запросе — ничего не делаем. if (settled) return; done({ ok: false, code: 400, message: 'bad request body' }); }); }); } // ───────────────────────────────────────────────────────────────────────────── // 1. validateInput // ───────────────────────────────────────────────────────────────────────────── /** * Валидирует POST-запрос: проверяет Content-Type, размер тела, читает и * разбирает JSON, извлекает непустой `question` (или `query`). * * @param {import('http').IncomingMessage} req * @returns {Promise<{ok:true, question:string} | * {ok:false, code:number, message:string}>} */ async function validateInput(req) { // Content-Type должен начинаться с application/json. const contentType = req.headers['content-type'] || ''; if (!contentType.toLowerCase().startsWith('application/json')) { return { ok: false, code: 415, message: 'unsupported media type' }; } // Content-Length больше лимита — отказ БЕЗ чтения тела. const maxBody = ENV.ASSIST_MAX_BODY; const contentLengthHeader = req.headers['content-length']; if (contentLengthHeader !== undefined) { const declared = Number.parseInt(contentLengthHeader, 10); if (Number.isFinite(declared) && declared > maxBody) { return { ok: false, code: 413, message: 'body too large' }; } } // Читаем тело со счётчиком (обрыв при превышении в процессе). const readResult = await readBodyWithLimit(req, { maxBytes: maxBody }); if (!readResult.ok) return readResult; let parsed; try { parsed = JSON.parse(readResult.body); } catch { return { ok: false, code: 400, message: 'invalid json' }; } // Извлекаем question/query; после trim — непустая строка. const raw = (parsed && (parsed.question ?? parsed.query)) || ''; const question = typeof raw === 'string' ? raw.trim() : ''; if (!question) { return { ok: false, code: 400, message: 'question is required' }; } return { ok: true, question }; } // ───────────────────────────────────────────────────────────────────────────── // 2. createRateLimiter // ───────────────────────────────────────────────────────────────────────────── /** * Фабрика in-memory rate-limiter со скользящим окном по ключу (например, IP). * * @param {{ * limit?:number, // базовый лимит запросов за окно * windowMs?:number, // размер окна, мс * replicas?:number, // делитель: реальный лимит = baseLimit / replicas * cleanupIntervalMs?:number // период ленивой чистки устаревших ключей * }} opts * @returns {{check:(key:string)=>{ok:boolean, retryAfterSec?:number}}} */ function createRateLimiter(opts = {}) { const { limit = ENV.ASSIST_RATE_PER_WINDOW, windowMs = ENV.ASSIST_RATE_WINDOW_MS, replicas = ENV.ASSIST_REPLICAS, cleanupIntervalMs = windowMs, } = opts; // Реальный лимит с учётом количества реплик (не меньше 1). const effectiveLimit = Math.max(1, Math.floor(limit / Math.max(1, replicas))); // key -> массив таймстемпов (мс) запросов в текущем окне. const buckets = new Map(); /** * Проверяет, можно ли пропустить запрос для ключа, и регистрирует его. * * @param {string} key * @returns {{ok:boolean, retryAfterSec?:number}} */ function check(key) { const now = Date.now(); const windowStart = now - windowMs; let bucket = buckets.get(key); if (!bucket) { bucket = []; buckets.set(key, bucket); } // Отбрасываем таймстемпы вне текущего окна (скользящее окно). while (bucket.length && bucket[0] <= windowStart) { bucket.shift(); } if (bucket.length >= effectiveLimit) { const oldest = bucket[0]; const retryAfterMs = Math.max(0, oldest + windowMs - now); return { ok: false, retryAfterSec: Math.ceil(retryAfterMs / 1000) }; } bucket.push(now); return { ok: true }; } // Периодическая чистка полностью устаревших ключей. const cleanupTimer = setInterval(() => { const now = Date.now(); const windowStart = now - windowMs; for (const [key, bucket] of buckets) { while (bucket.length && bucket[0] <= windowStart) { bucket.shift(); } if (bucket.length === 0) { buckets.delete(key); } } }, cleanupIntervalMs); // Не даём интервалу удерживать процесс (для чистоты в тестах/CLI). if (typeof cleanupTimer.unref === 'function') { cleanupTimer.unref(); } return { check }; } // ───────────────────────────────────────────────────────────────────────────── // 3. createSemaphore // ───────────────────────────────────────────────────────────────────────────── /** * Простой неблокирующий семафор на max одновременных «вхождений». * * @param {number} max * @returns {{tryAcquire:()=>boolean, release:()=>void}} */ function createSemaphore(max) { const cap = Math.max(1, Math.floor(max)); let used = 0; return { /** Пытается занять слот. Вернёт false, если слотов нет. */ tryAcquire() { if (used >= cap) return false; used += 1; return true; }, /** Освобождает занятый слот. */ release() { if (used > 0) used -= 1; }, }; } // ───────────────────────────────────────────────────────────────────────────── // 4. llmChat // ───────────────────────────────────────────────────────────────────────────── /** * Вызывает LLM через OpenAI-совместимый эндпоинт /v1/chat/completions. * * @param {{ * messages:Array, // массив сообщений {role, content} * maxTokens?:number, * controller?:AbortController, // сигнал отмены * baseUrl?:string, * apiKey?:string, * model?:string, * temperature?:number, * responseFormat?:{type:string} // напр. {type:'json_object'} * usageRef?:object // мутабельный объект для сбора токенов {prompt,completion,total} * }} params * @returns {Promise} — содержимое choices[0].message.content */ async function llmChat({ messages, maxTokens, controller, baseUrl = ENV.LLM_BASE_URL, apiKey = ENV.LLM_API_KEY, model = ENV.LLM_MODEL, temperature, responseFormat, usageRef, }) { const base = String(baseUrl).replace(/\/+$/, ''); const url = `${base}/v1/chat/completions`; const payload = { model, messages }; if (maxTokens !== undefined && maxTokens !== null) { payload.max_tokens = maxTokens; } if (temperature !== undefined && temperature !== null) { payload.temperature = temperature; } if (responseFormat !== undefined && responseFormat !== null) { payload.response_format = responseFormat; } const signal = controller ? controller.signal : undefined; let response; try { response = await fetch(url, { method: 'POST', headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json', }, body: JSON.stringify(payload), signal, }); } catch (err) { if (err && err.name === 'AbortError') { throw { code: 'aborted', message: 'llm request aborted (AbortError)' }; } throw new Error('llm request failed (network)'); } // Не-200 → ошибка со статусом (без apiKey в тексте!). if (!response.ok) { const status = response.status; const retryAfter = response.headers.get('retry-after'); const err = { code: 'llm_http_error', status, retryAfter: undefined }; if (status === 429) { err.code = 'rate_limited'; // retry-after может быть секундами или HTTP-датой. if (retryAfter) { const secs = parseRetryAfter(retryAfter); if (secs !== null) err.retryAfter = secs; } } // Читаем тело ошибки, но никогда не упоминаем apiKey. let detail = ''; try { detail = await response.text(); if (detail && detail.length > 500) detail = detail.slice(0, 500); } catch { detail = ''; } if (detail) err.message = `llm error ${status}: ${detail}`; else err.message = `llm error ${status}`; throw err; } const data = await response.json(); // Собираем токены (usage) для отображения пользователю. if (usageRef && data && data.usage) { usageRef.prompt = (usageRef.prompt || 0) + (data.usage.prompt_tokens || 0); usageRef.completion = (usageRef.completion || 0) + (data.usage.completion_tokens || 0); usageRef.total = (usageRef.total || 0) + (data.usage.total_tokens || 0); } const content = data && data.choices && data.choices[0] && data.choices[0].message && data.choices[0].message.content; if (typeof content !== 'string' || content.trim() === '') { throw { code: 'empty_content', message: 'llm returned empty content' }; } return content; } /** * Разбирает значение заголовка Retry-After (секунды или HTTP-дата) в секунды. * * @param {string} value * @returns {number|null} */ function parseRetryAfter(value) { if (!value) return null; const num = Number.parseInt(value, 10); if (Number.isFinite(num)) return num; const date = Date.parse(value); if (Number.isFinite(date)) { return Math.max(0, Math.ceil((date - Date.now()) / 1000)); } return null; } // ───────────────────────────────────────────────────────────────────────────── // 5. normalizeQuery // ───────────────────────────────────────────────────────────────────────────── /** * Нормализует запрос для использования в качестве ключа кэша: * lowercase, схлопывание пробелов, обрезка краёв. * * @param {string} q * @returns {string} */ function normalizeQuery(q) { return String(q).toLowerCase().replace(/\s+/g, ' ').trim(); } // ───────────────────────────────────────────────────────────────────────────── // 6. jsonError // ───────────────────────────────────────────────────────────────────────────── /** * Пишет JSON-ошибку вида {"error":code,"message":...} в HTTP-ответ. * * @param {import('http').ServerResponse} res * @param {number} status * @param {string|number} code * @param {string} message * @param {Record|object} [headers] */ function jsonError(res, status, code, message, headers = {}) { const body = JSON.stringify({ error: code, message }); const finalHeaders = { 'Content-Type': 'application/json; charset=utf-8', 'Content-Length': Buffer.byteLength(body), ...headers, }; if (!res.headersSent) { res.writeHead(status, finalHeaders); } res.end(body); } // ───────────────────────────────────────────────────────────────────────────── // Экспорт // ───────────────────────────────────────────────────────────────────────────── module.exports = { validateInput, createRateLimiter, createSemaphore, llmChat, normalizeQuery, jsonError, }; // Дополнительно: ENV и maskKey доступны для внутренних нужд/тестов. module.exports.ENV = ENV; module.exports.maskKey = maskKey;