feat(assist): каркас assist.js (validateInput, rate-limit, semaphore, llmChat, jsonError)

This commit is contained in:
Repinoid
2026-10-07 13:41:45 +03:00
parent cff0af286b
commit e419b38a25
+430
View File
@@ -0,0 +1,430 @@
'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
* }} params
* @returns {Promise<string>} — содержимое 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,
}) {
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;
}
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();
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<string,string|number>|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;