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.
This commit is contained in:
Repinoid
2026-10-12 07:51:48 +03:00
parent 5ab9274015
commit 12d29ee98d
4 changed files with 139 additions and 100 deletions
+103 -60
View File
@@ -266,7 +266,8 @@ function createSemaphore(max) {
* @param {{
* messages:Array, // массив сообщений {role, content}
* maxTokens?:number,
* controller?:AbortController, // сигнал отмены
* controller?:AbortController, // сигнал отмены вызывающего (дедлайн/закрытие соединения)
* timeoutMs?:number, // таймаут ОДНОГО вызова ЛЛМ (по умолч. ASSIST_LLM_TIMEOUT_MS)
* baseUrl?:string,
* apiKey?:string,
* model?:string,
@@ -280,6 +281,7 @@ 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,
@@ -301,72 +303,113 @@ async function llmChat({
payload.response_format = responseFormat;
}
const signal = controller ? controller.signal : undefined;
// Составной сигнал отмены. 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);
}
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 = '';
let response;
try {
detail = await response.text();
if (detail && detail.length > 500) detail = detail.slice(0, 500);
} catch {
detail = '';
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)');
}
if (detail) err.message = `llm error ${status}: ${detail}`;
else err.message = `llm error ${status}`;
// Не-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);
}
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;
}
/**