mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-13 18:32:12 +03:00
fix(sse): refuse an AI Horde queue that cannot fit the request budget (#12143)
Validado em worktree combinada com typecheck limpo, testes focados verdes, gates de file-size/complexity/cognitive-complexity/cycles OK. Obrigado!
This commit is contained in:
committed by
GitHub
parent
8b7afc0eba
commit
43f2b2c288
@@ -17,7 +17,11 @@ import {
|
||||
} from "./aihordeMapRequest.ts";
|
||||
|
||||
const GENERATE_TIMEOUT_MS = 600_000;
|
||||
const POLL_INTERVAL_MS = 1_000;
|
||||
// Опрос начинается частым и разряжается по мере ожидания: короткая очередь
|
||||
// отдаёт картинку за секунды, а длинная иначе стоила бы Horde сотен запросов
|
||||
// по общему анонимному ключу (600 опросов на один кадр при полном бюджете).
|
||||
const POLL_INTERVAL_MIN_MS = 1_000;
|
||||
const POLL_INTERVAL_MAX_MS = 8_000;
|
||||
// Per-call bound for the Horde API's own submit/check/status/cancel calls
|
||||
// (a fixed, trusted host — no SSRF guard needed, just a hard timeout so a
|
||||
// hung upstream cannot stall a request indefinitely). Individual calls are
|
||||
@@ -119,6 +123,11 @@ async function fetchHordeImageBytes(
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Числовое поле ответа Horde: отсутствующее или нечисловое читается как «неизвестно». */
|
||||
function numericField(value: unknown): number | null {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
export async function handleAiHordeImageGeneration({
|
||||
model,
|
||||
provider,
|
||||
@@ -212,19 +221,23 @@ export async function handleAiHordeImageGeneration({
|
||||
}
|
||||
|
||||
let completed = false;
|
||||
let pollDelayMs = POLL_INTERVAL_MIN_MS;
|
||||
try {
|
||||
while (true) {
|
||||
if (signal?.aborted) throw new Error("Horde image generation cancelled");
|
||||
if (Date.now() >= deadline) {
|
||||
throw Object.assign(new Error("Horde image generation timed out"), { status: 504 });
|
||||
}
|
||||
await sleep(POLL_INTERVAL_MS);
|
||||
const checkRes = await safeOutboundFetch(`${AI_HORDE_API_BASE}/v2/generate/check/${jobId}`, {
|
||||
headers: hordeHeaders(apiKey),
|
||||
signal: signal ?? undefined,
|
||||
guard: "none",
|
||||
timeoutMs: boundedTimeoutMs(deadline, HORDE_API_CALL_TIMEOUT_MS),
|
||||
});
|
||||
await sleep(pollDelayMs);
|
||||
const checkRes = await safeOutboundFetch(
|
||||
`${AI_HORDE_API_BASE}/v2/generate/check/${jobId}`,
|
||||
{
|
||||
headers: hordeHeaders(apiKey),
|
||||
signal: signal ?? undefined,
|
||||
guard: "none",
|
||||
timeoutMs: boundedTimeoutMs(deadline, HORDE_API_CALL_TIMEOUT_MS),
|
||||
}
|
||||
);
|
||||
const check = await safeJson(checkRes);
|
||||
if (!checkRes.ok || !check || typeof check !== "object") {
|
||||
throw Object.assign(
|
||||
@@ -239,14 +252,55 @@ export async function handleAiHordeImageGeneration({
|
||||
status: 503,
|
||||
});
|
||||
}
|
||||
if (!checkObj.done) continue;
|
||||
if (!checkObj.done) {
|
||||
// Horde сообщает оценку ожидания в первом же ответе. Если она не
|
||||
// помещается в остаток бюджета, ждать нечего: запрос всё равно
|
||||
// упал бы по таймауту, только молча и десятью минутами позже.
|
||||
// Отказ называет очередь и число воркеров — по ним видно, что
|
||||
// выручает не терпение, а модель с большим числом воркеров.
|
||||
const waitSeconds = numericField(checkObj.wait_time);
|
||||
const remainingMs = deadline - Date.now();
|
||||
if (waitSeconds !== null && waitSeconds * 1_000 > remainingMs) {
|
||||
const queuePosition = numericField(checkObj.queue_position);
|
||||
const workers = numericField(checkObj.eligible_workers);
|
||||
const details = [
|
||||
`queue wait ~${Math.round(waitSeconds)}s`,
|
||||
queuePosition !== null ? `position ${queuePosition}` : null,
|
||||
workers !== null ? `${workers} eligible worker(s)` : null,
|
||||
`budget ${Math.round(remainingMs / 1_000)}s left`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
throw Object.assign(
|
||||
new Error(
|
||||
`Horde queue is longer than the request budget (${details}). ` +
|
||||
`Pick a model with more workers or raise the timeout.`
|
||||
),
|
||||
{ status: 504 }
|
||||
);
|
||||
}
|
||||
// Разрядка опроса: десятая доля оставшегося ожидания, в рамках
|
||||
// минимума и максимума. Короткая очередь по-прежнему опрашивается
|
||||
// раз в секунду.
|
||||
pollDelayMs =
|
||||
waitSeconds === null
|
||||
? POLL_INTERVAL_MIN_MS
|
||||
: Math.min(
|
||||
POLL_INTERVAL_MAX_MS,
|
||||
Math.max(POLL_INTERVAL_MIN_MS, Math.round((waitSeconds * 1_000) / 10))
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const statusRes = await safeOutboundFetch(`${AI_HORDE_API_BASE}/v2/generate/status/${jobId}`, {
|
||||
headers: hordeHeaders(apiKey),
|
||||
signal: signal ?? undefined,
|
||||
guard: "none",
|
||||
timeoutMs: boundedTimeoutMs(deadline, HORDE_API_CALL_TIMEOUT_MS),
|
||||
});
|
||||
const statusRes = await safeOutboundFetch(
|
||||
`${AI_HORDE_API_BASE}/v2/generate/status/${jobId}`,
|
||||
{
|
||||
headers: hordeHeaders(apiKey),
|
||||
signal: signal ?? undefined,
|
||||
guard: "none",
|
||||
timeoutMs: boundedTimeoutMs(deadline, HORDE_API_CALL_TIMEOUT_MS),
|
||||
}
|
||||
);
|
||||
const status = await safeJson(statusRes);
|
||||
if (!statusRes.ok || !status || typeof status !== "object") {
|
||||
throw Object.assign(
|
||||
|
||||
114
tests/unit/aihorde-queue-budget.test.ts
Normal file
114
tests/unit/aihorde-queue-budget.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-aihorde-queue-"));
|
||||
|
||||
import { handleAiHordeImageGeneration } from "../../open-sse/handlers/imageGeneration/providers/aihorde.ts";
|
||||
import { aiHordeImageCatalog } from "../../open-sse/services/aihordeImageCatalog.ts";
|
||||
|
||||
/**
|
||||
* Очередь Horde известна с первого ответа — отказывать надо там же.
|
||||
*
|
||||
* `/v2/generate/check` возвращает `wait_time` и `queue_position` сразу. Пока
|
||||
* они не читались, запрос на модель с длинной очередью опрашивал Horde раз в
|
||||
* секунду весь бюджет и падал по таймауту, ничего не объяснив. Живая проверка
|
||||
* 2026-08-30: модель `Deliberate` (3 воркера) ответила `wait_time: 1478,
|
||||
* queue_position: 321` — 25 минут при бюджете в 10. Ждать было бессмысленно
|
||||
* ещё до первого опроса, а пользователь узнавал об этом через десять минут.
|
||||
*
|
||||
* Для сравнения `stable_diffusion` (10 воркеров) в тот же момент отдал картинку
|
||||
* за 10.4 секунды — то есть отказ должен быть про эту модель и эту очередь, а
|
||||
* не про Horde вообще, и должен подсказывать, что делать.
|
||||
*/
|
||||
|
||||
const HORDE_JOB_ID = "queue-budget-job";
|
||||
|
||||
function stubHordeQueue({ waitTimeSeconds }: { waitTimeSeconds: number }) {
|
||||
const calls: string[] = [];
|
||||
globalThis.fetch = (async (input: string | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
const method = (init?.method || "GET").toUpperCase();
|
||||
calls.push(`${method} ${url}`);
|
||||
|
||||
if (url.endsWith("/v2/generate/async")) {
|
||||
return new Response(JSON.stringify({ id: HORDE_JOB_ID, kudos: 6 }), { status: 202 });
|
||||
}
|
||||
if (url.includes("/v2/generate/check/")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
done: false,
|
||||
faulted: false,
|
||||
is_possible: true,
|
||||
waiting: 1,
|
||||
wait_time: waitTimeSeconds,
|
||||
queue_position: 321,
|
||||
eligible_workers: 3,
|
||||
}),
|
||||
{ status: 200 }
|
||||
);
|
||||
}
|
||||
return new Response("{}", { status: 200 });
|
||||
}) as typeof fetch;
|
||||
return calls;
|
||||
}
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
test.beforeEach(() => {
|
||||
aiHordeImageCatalog.replace([
|
||||
{ name: "Deliberate", count: 3, queued: 0, eta: 1478, performance: 1, jobs: 0 },
|
||||
]);
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
test("refuses immediately when the queue cannot fit the remaining budget", async () => {
|
||||
const calls = stubHordeQueue({ waitTimeSeconds: 1478 });
|
||||
|
||||
const started = Date.now();
|
||||
const result = await handleAiHordeImageGeneration({
|
||||
model: "Deliberate",
|
||||
provider: "aihorde",
|
||||
body: { model: "aihorde/Deliberate", prompt: "hello world" },
|
||||
credentials: { apiKey: "horde-key" },
|
||||
timeoutMs: 600_000,
|
||||
});
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.equal(result.status, 504);
|
||||
assert.match(String(result.error), /queue/i);
|
||||
assert.match(String(result.error), /1478|25/, "в отказе должно быть названо ожидание");
|
||||
|
||||
assert.ok(
|
||||
Date.now() - started < 30_000,
|
||||
"отказ обязан прийти сразу, а не после выработки бюджета"
|
||||
);
|
||||
const checks = calls.filter((c) => c.includes("/v2/generate/check/"));
|
||||
assert.equal(checks.length, 1, "хватает одного опроса, чтобы узнать очередь");
|
||||
});
|
||||
|
||||
test("keeps waiting when the queue fits the budget", async () => {
|
||||
// Ожидание заведомо помещается в бюджет: 2 секунды очереди против 6.
|
||||
const calls = stubHordeQueue({ waitTimeSeconds: 2 });
|
||||
|
||||
const result = await handleAiHordeImageGeneration({
|
||||
model: "Deliberate",
|
||||
provider: "aihorde",
|
||||
body: { model: "aihorde/Deliberate", prompt: "hello world" },
|
||||
credentials: { apiKey: "horde-key" },
|
||||
timeoutMs: 6_000,
|
||||
});
|
||||
|
||||
// Заглушка никогда не отвечает done, поэтому запрос доходит до собственного
|
||||
// таймаута — важно, что он до него дошёл, а не был отбит по очереди.
|
||||
assert.equal(result.success, false);
|
||||
assert.ok(
|
||||
calls.filter((c) => c.includes("/v2/generate/check/")).length > 1,
|
||||
"короткая очередь не должна приводить к раннему отказу"
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user