Files
Repinoid 12d29ee98d fix: поиск по подресурсам + реальный per-call таймаут ЛЛМ
search(resourceBaseSegment): суффиксы-подресурсы перебираются по убыванию
длины (KNOWN_RESOURCE_SUBS). Иначе "_user" матчился раньше "_sub_user" и
nubes_s3_sub_user давал "s3_sub" вместо "s3" — страница ресурса не находилась.

assist(llmChat): реально применяет ASSIST_LLM_TIMEOUT_MS — таймаут ОДНОГО
вызова (покрывает и чтение тела ответа). Таймаут возвращает отдельный код
llm_timeout, отмена вызывающим (дедлайн/закрытие соединения) — по-прежнему
aborted, чтобы stage2 различал причины.

stage1: убран дублирующий AbortController (таймаут теперь только в llmChat).

orchestrator: llm_timeout -> HTTP 504.
2026-10-12 07:51:48 +03:00

489 lines
22 KiB
JavaScript
Raw Permalink 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.
'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, // сигнал отмены вызывающего (дедлайн/закрытие соединения)
* timeoutMs?:number, // таймаут ОДНОГО вызова ЛЛМ (по умолч. ASSIST_LLM_TIMEOUT_MS)
* baseUrl?:string,
* apiKey?:string,
* model?:string,
* temperature?:number,
* responseFormat?:{type:string} // напр. {type:'json_object'}
* usageRef?:object // мутабельный объект для сбора токенов {prompt,completion,total}
* }} params
* @returns {Promise<string>} — содержимое choices[0].message.content
*/
async function llmChat({
messages,
maxTokens,
controller,
timeoutMs = ENV.ASSIST_LLM_TIMEOUT_MS,
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;
}
// Составной сигнал отмены. fetch принимает РОВНО ОДИН signal, поэтому, когда
// задан индивидуальный таймаут вызова (ASSIST_LLM_TIMEOUT_MS), создаём
// дочерний AbortController, который абортится ЛИБО по таймауту, ЛИБО при
// аборте вызывающего (дедлайн всего запроса / закрытие соединения клиентом).
// Без таймаута используем сигнал вызывающего напрямую. Дочерний контроллер
// покрывает ВЕСЬ вызов, включая чтение тела ответа (response.json()).
let signal = controller ? controller.signal : undefined;
let timeoutTimer = null;
let onParentAbort = null;
if (timeoutMs && Number.isFinite(timeoutMs) && timeoutMs > 0) {
const ownController = new AbortController();
signal = ownController.signal;
if (controller) {
onParentAbort = () => ownController.abort();
if (controller.signal.aborted) ownController.abort();
else controller.signal.addEventListener('abort', onParentAbort, { once: true });
}
timeoutTimer = setTimeout(() => ownController.abort(), timeoutMs);
}
try {
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') {
// Различаем причину: отмена вызывающим vs. собственный таймаут
// вызова (иначе stage2 не смог бы отличить таймаут от ухода
// клиента и корректно решить, повторять ли вызов).
if (controller && controller.signal.aborted) {
throw { code: 'aborted', message: 'llm request aborted (AbortError)' };
}
throw { code: 'llm_timeout', message: `llm call exceeded timeout ${timeoutMs} ms` };
}
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;
} catch (err) {
// Аборт мог прилететь не только из fetch, но и при чтении тела ответа
// (response.json() выше) — нормализуем и этот случай тем же образом.
if (err && err.name === 'AbortError') {
if (controller && controller.signal.aborted) {
throw { code: 'aborted', message: 'llm request aborted (AbortError)' };
}
throw { code: 'llm_timeout', message: `llm call exceeded timeout ${timeoutMs} ms` };
}
throw err;
} finally {
// Снимаем таймер и слушателя родителя на всех путях (успех/ошибка),
// чтобы не оставлять висящих таймеров и подписок на сигнале.
if (timeoutTimer) clearTimeout(timeoutTimer);
if (controller && onParentAbort) controller.signal.removeEventListener('abort', onParentAbort);
}
}
/**
* Разбирает значение заголовка 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;