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:
@@ -266,7 +266,8 @@ function createSemaphore(max) {
|
|||||||
* @param {{
|
* @param {{
|
||||||
* messages:Array, // массив сообщений {role, content}
|
* messages:Array, // массив сообщений {role, content}
|
||||||
* maxTokens?:number,
|
* maxTokens?:number,
|
||||||
* controller?:AbortController, // сигнал отмены
|
* controller?:AbortController, // сигнал отмены вызывающего (дедлайн/закрытие соединения)
|
||||||
|
* timeoutMs?:number, // таймаут ОДНОГО вызова ЛЛМ (по умолч. ASSIST_LLM_TIMEOUT_MS)
|
||||||
* baseUrl?:string,
|
* baseUrl?:string,
|
||||||
* apiKey?:string,
|
* apiKey?:string,
|
||||||
* model?:string,
|
* model?:string,
|
||||||
@@ -280,6 +281,7 @@ async function llmChat({
|
|||||||
messages,
|
messages,
|
||||||
maxTokens,
|
maxTokens,
|
||||||
controller,
|
controller,
|
||||||
|
timeoutMs = ENV.ASSIST_LLM_TIMEOUT_MS,
|
||||||
baseUrl = ENV.LLM_BASE_URL,
|
baseUrl = ENV.LLM_BASE_URL,
|
||||||
apiKey = ENV.LLM_API_KEY,
|
apiKey = ENV.LLM_API_KEY,
|
||||||
model = ENV.LLM_MODEL,
|
model = ENV.LLM_MODEL,
|
||||||
@@ -301,72 +303,113 @@ async function llmChat({
|
|||||||
payload.response_format = responseFormat;
|
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 {
|
try {
|
||||||
response = await fetch(url, {
|
let response;
|
||||||
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 {
|
try {
|
||||||
detail = await response.text();
|
response = await fetch(url, {
|
||||||
if (detail && detail.length > 500) detail = detail.slice(0, 500);
|
method: 'POST',
|
||||||
} catch {
|
headers: {
|
||||||
detail = '';
|
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;
|
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -342,6 +342,7 @@ function mapPipelineError(code) {
|
|||||||
case 'index_unavailable': return 503;
|
case 'index_unavailable': return 503;
|
||||||
case 'empty_content': return 502;
|
case 'empty_content': return 502;
|
||||||
case 'aborted': return 499;
|
case 'aborted': return 499;
|
||||||
|
case 'llm_timeout': return 504;
|
||||||
case 'rate_limited': return 503;
|
case 'rate_limited': return 503;
|
||||||
default: return 502;
|
default: return 502;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,6 +190,22 @@ function pageKind(seg) {
|
|||||||
return "resource";
|
return "resource";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Суффиксы-подресурсы в именах `nubes_*`, по которым отрезается хвост до
|
||||||
|
* базового ресурса (например, `nubes_postgres_database` → `postgres`).
|
||||||
|
*
|
||||||
|
* ⚠️ Порядок перебора критичен: суффиксы идут по УБЫВАНИЮ длины, поэтому
|
||||||
|
* сортируем массив один раз при загрузке модуля. Иначе короткий `_user`
|
||||||
|
* затеняет более длинный `_sub_user`: для `nubes_s3_sub_user` (rest =
|
||||||
|
* "s3_sub_user") отрезался бы `_user`, и получалось "s3_sub" вместо "s3",
|
||||||
|
* из-за чего страница ресурса не находилась (`isResourcePage`).
|
||||||
|
*/
|
||||||
|
const KNOWN_RESOURCE_SUBS = [
|
||||||
|
"_database", "_user", "_topic", "_backup", "_snat", "_record",
|
||||||
|
"_sub_user", "_ip_allocation", "_vdc", "_kv", "_policy", "_secrets",
|
||||||
|
"_team", "_redeploy", "_group",
|
||||||
|
].sort((a, b) => b.length - a.length);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Преобразует имя ресурса nubes_<база>_<подресурс> в базовое имя страницы
|
* Преобразует имя ресурса nubes_<база>_<подресурс> в базовое имя страницы
|
||||||
* (<база>). Для `nubes_postgres_database` → `postgres`; `nubes_postgres` → `postgres`.
|
* (<база>). Для `nubes_postgres_database` → `postgres`; `nubes_postgres` → `postgres`.
|
||||||
@@ -201,15 +217,10 @@ function resourceBaseSegment(resourceType) {
|
|||||||
if (!resourceType || !resourceType.startsWith("nubes_")) return null;
|
if (!resourceType || !resourceType.startsWith("nubes_")) return null;
|
||||||
const rest = resourceType.slice("nubes_".length);
|
const rest = resourceType.slice("nubes_".length);
|
||||||
if (!rest) return null;
|
if (!rest) return null;
|
||||||
// Известные подресурсы (суффиксы) — отрезаем до базового ресурса.
|
// Известные суффиксы-подресурсы; перебор идёт от самого длинного к самому
|
||||||
// Берём первую часть до суффикса-подресурса, если составное имя содержит известный подресурс.
|
// короткому (порядок задан в KNOWN_RESOURCE_SUBS, см. определение выше).
|
||||||
const knownSubs = [
|
|
||||||
"_database", "_user", "_topic", "_backup", "_snat", "_record",
|
|
||||||
"_sub_user", "_ip_allocation", "_vdc", "_kv", "_policy", "_secrets",
|
|
||||||
"_team", "_redeploy", "_group",
|
|
||||||
];
|
|
||||||
let base = rest;
|
let base = rest;
|
||||||
for (const sub of knownSubs) {
|
for (const sub of KNOWN_RESOURCE_SUBS) {
|
||||||
if (rest.endsWith(sub)) {
|
if (rest.endsWith(sub)) {
|
||||||
base = rest.slice(0, rest.length - sub.length);
|
base = rest.slice(0, rest.length - sub.length);
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -131,38 +131,22 @@ async function stage1ExtractResourceType(question, {
|
|||||||
|
|
||||||
const messages = buildStage1Messages(question, types);
|
const messages = buildStage1Messages(question, types);
|
||||||
|
|
||||||
const attempt = async (maxTokens) => {
|
// Индивидуальный таймаут одного вызова ЛЛМ и составной сигнал отмены
|
||||||
const params = {
|
// реализованы ВНУТРИ llmChat (единая точка). Здесь только прокидываем опции,
|
||||||
messages,
|
// чтобы не создавать ДВОЙНОЙ AbortController. timeoutMs === undefined →
|
||||||
maxTokens,
|
// llmChat применит дефолт ASSIST_LLM_TIMEOUT_MS.
|
||||||
temperature: 0,
|
const attempt = async (maxTokens) => llmChat({
|
||||||
responseFormat: { type: 'json_object' },
|
messages,
|
||||||
controller,
|
maxTokens,
|
||||||
baseUrl,
|
temperature: 0,
|
||||||
apiKey,
|
responseFormat: { type: 'json_object' },
|
||||||
model,
|
controller,
|
||||||
usageRef,
|
timeoutMs,
|
||||||
};
|
baseUrl,
|
||||||
let timer = null;
|
apiKey,
|
||||||
let ctrl = controller;
|
model,
|
||||||
if (timeoutMs && Number.isFinite(timeoutMs) && timeoutMs > 0) {
|
usageRef,
|
||||||
ctrl = new AbortController();
|
});
|
||||||
timer = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
||||||
const forward = () => ctrl.abort();
|
|
||||||
if (controller) {
|
|
||||||
if (controller.signal.aborted) ctrl.abort();
|
|
||||||
else controller.signal.addEventListener('abort', forward, { once: true });
|
|
||||||
}
|
|
||||||
params.controller = ctrl;
|
|
||||||
try {
|
|
||||||
return await llmChat(params);
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timer);
|
|
||||||
if (controller) controller.signal.removeEventListener('abort', forward);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return llmChat(params);
|
|
||||||
};
|
|
||||||
|
|
||||||
let content;
|
let content;
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user