Merge remote-tracking branch 'origin/release/v3.8.51' into chore/12146-batch4-shared-components

This commit is contained in:
diegosouzapw
2026-08-30 20:36:37 -03:00
22 changed files with 1161 additions and 91 deletions

View File

@@ -176,13 +176,14 @@ function isWithinRoot(ancestor, candidate) {
* Register the ESM resolve hook for the current process. Safe to call multiple
* times — subsequent calls are no-ops once the hook is installed.
*
* Uses Node's stable `module.register()` API (available since Node 20.6,
* required Node 22+ here). The hook runs in a worker thread but only reads the
* captured `root`, so no shared-state hazards.
* Modern runtimes import the hook module in-thread, initialize its root with a
* plain function call, and register its synchronous resolver through
* `module.registerHooks()`. Runtimes without that API (notably Bun) retain the
* `module.register()` worker-thread loader lifecycle path.
*
* @param {string} root Absolute path to the package root.
* @returns {Promise<boolean>} Resolves `true` once registered (or if already
* registered), `false` on environments where `module.register` is unavailable.
* registered), `false` when neither registration API is usable.
*/
let _registered = false;
export async function registerAliasResolver(root) {
@@ -201,7 +202,7 @@ export async function registerAliasResolver(root) {
}
try {
const { register } = await import("node:module");
const mod = await import("node:module");
// #7808: load the hook from a real file on disk via pathToFileURL() instead
// of building a `data:text/javascript,...` URL dynamically. CodeQL's
// `js/incomplete-url-substring-sanitization` flagged the interpolated
@@ -211,14 +212,21 @@ export async function registerAliasResolver(root) {
// package.json "files": ["bin/"].
const hookPath = join(__dirname, "aliasResolverHook.mjs");
const hookUrl = pathToFileURL(hookPath);
register(hookUrl, { data: { root } });
if (typeof mod.registerHooks === "function") {
const hook = await import(hookUrl.href);
hook.initialize({ root });
mod.registerHooks({ resolve: hook.resolve });
_registered = true;
return true;
}
mod.register(hookUrl, { data: { root } });
_registered = true;
return true;
} catch {
// Older Node or sandboxed env without module.register — fall back to the
// default resolver. The bug will resurface only in the exact global-install
// scenario, which is what we explicitly patched; other entry points still
// work because they import via relative paths.
// Runtime or sandboxed env without a usable module hook API — fall back to
// the default resolver. The bug will resurface only in the exact
// global-install scenario, which is what we explicitly patched; other entry
// points still work because they import via relative paths.
return false;
}
}

View File

@@ -0,0 +1 @@
- **fix(cli):** use in-thread alias resolver hooks on modern runtimes to avoid deprecation noise and improve Node.js forward compatibility ([#12073](https://github.com/diegosouzapw/OmniRoute/issues/12073)).

View File

@@ -3496,11 +3496,6 @@
"count": 1
}
},
"tests/unit/call-log-cap.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 45
}
},
"tests/unit/call-log-startup.test.ts": {
"@typescript-eslint/no-explicit-any": {
"count": 1

View File

@@ -175,6 +175,7 @@ export const HTTP_STATUS = {
REQUEST_TIMEOUT: 408,
GONE: 410,
RATE_LIMITED: 429,
PLAN_LIMIT_EXCEEDED: 432,
SERVER_ERROR: 500,
BAD_GATEWAY: 502,
SERVICE_UNAVAILABLE: 503,

View File

@@ -190,6 +190,13 @@ export const VIDEO_PROVIDERS: Record<string, VideoProvider> = {
authType: "apikey",
authHeader: "bearer",
format: "pollinations-video",
// Живая проверка 2026-08-30: диспетчер videoGeneration.ts не разбирает
// "pollinations-video" и отвечает 400 Unsupported video format — модель
// висела в выдаче каталога, но не исполнялась ни при каких ключах.
unsupported: true,
unsupportedReason:
"Pollinations video has no submit/poll transport in the dispatcher yet. " +
"Use an image model or another video provider until one is added.",
models: [{ id: "default", name: "Pollinations Video (Free)" }],
},
@@ -200,6 +207,12 @@ export const VIDEO_PROVIDERS: Record<string, VideoProvider> = {
authType: "apikey",
authHeader: "bearer",
format: "minimax-video",
// Живая проверка 2026-08-30: 400 Unsupported video format на всех трёх
// моделях Hailuo. Свой submit → query API, не покрытый job-пресетами.
unsupported: true,
unsupportedReason:
"MiniMax video uses its own submit/query transport that the dispatcher " +
"does not implement yet. Generate video via another provider for now.",
models: [
{ id: "MiniMax-Hailuo-2.3", name: "Hailuo 2.3" },
{ id: "MiniMax-Hailuo-02", name: "Hailuo 02" },
@@ -214,6 +227,12 @@ export const VIDEO_PROVIDERS: Record<string, VideoProvider> = {
authType: "apikey",
authHeader: "bearer",
format: "together-video",
// Не рекламируется по той же причине, что pollinations/minimax: формат
// объявлен, ветки в диспетчере нет (проверено разбором 2026-08-30).
unsupported: true,
unsupportedReason:
"Together video has no transport in the dispatcher yet. " +
"Use another video provider until one is added.",
models: [
{ id: "wan-ai/wan2.1-t2v-480p", name: "Wan 2.1 T2V 480p" },
{ id: "wan-ai/wan2.7-t2v", name: "Wan 2.7 T2V" },
@@ -227,6 +246,12 @@ export const VIDEO_PROVIDERS: Record<string, VideoProvider> = {
authType: "apikey",
authHeader: "bearer",
format: "replicate-video",
// Не рекламируется: формат объявлен, ветки в диспетчере нет
// (проверено разбором 2026-08-30).
unsupported: true,
unsupportedReason:
"Replicate video has no prediction submit/poll transport in the " +
"dispatcher yet. Use another video provider until one is added.",
models: [
{ id: "minimax/video-01", name: "MiniMax Video 01" },
{ id: "wan-ai/wan2.1-t2v-480p", name: "Wan 2.1 T2V" },
@@ -394,7 +419,20 @@ export const VIDEO_PROVIDERS: Record<string, VideoProvider> = {
baseUrl: "https://nano-gpt.com/api/v1/video/generations",
authType: "apikey",
authHeader: "bearer",
format: "openai",
// Диспетчер знает формат под именем "openai-video" — под "openai" ветки нет,
// и провайдер отдавал 400 Unsupported video format (живая проверка
// 2026-08-30). Тот же обработчик обслуживает кастомные OpenAI-совместимые
// ноды, а baseUrl выше — ровно их путь.
format: "openai-video",
// Живая проверка 2026-08-30: адрес выше отдаёт 404 (HTML-страница), как и
// вариант во множественном числе /api/v1/videos/generations. Контроль на том
// же ключе: /api/v1/images/generations отвечает 401 JSON — то есть 404 здесь
// значит «маршрута нет», а не «ключ не тот». Формат исправлен на рабочее имя
// заранее, чтобы провайдер ожил правкой одного адреса, когда он появится.
unsupported: true,
unsupportedReason:
"NanoGPT video endpoint returns 404 — no video route is published under " +
"/api/v1/video(s)/generations. Use another video provider.",
models: [{ id: "default", name: "NanoGPT Video" }],
},
};

View File

@@ -131,7 +131,7 @@ import { resolveChatCoreRequestFormat } from "./chatCore/requestFormat.ts";
import { resolveChatCoreTargetFormat } from "./chatCore/targetFormat.ts";
import { resolveOmniGlyphTransport } from "../services/compression/imageTransportPolicy.ts";
import { stripStore, usesClaudeBridge } from "./chatCore/agentRouterProtocol.ts";
import { defaultClaudeToolType } from "./chatCore/claudeToolDefaults.ts";
import { normalizeClaudeToolsForDispatch } from "./chatCore/claudeToolDefaults.ts";
import { injectSystemPrompt, injectCustomSystemPrompt } from "../services/systemPrompt.ts";
import { translateRequest, needsTranslation } from "../translator/index.ts";
import { FORMATS } from "../translator/formats.ts";
@@ -2615,9 +2615,13 @@ export async function handleChatCore({
// definitions that omit the required `type` discriminator with HTTP 400. Default
// a missing `type` to "custom" before dispatch, mirroring Anthropic's own
// inference, so legacy Claude-format tool payloads survive strict gateways (#2195).
// AgentRouter is the opposite quirk: its Rust deserializer only accepts versioned
// tool types and 400s on `type: "custom"` — there the discriminator is stripped
// instead (see claudeToolDefaults.ts).
if (targetFormat === FORMATS.CLAUDE && Array.isArray(translatedBody.tools)) {
translatedBody.tools = defaultClaudeToolType(
translatedBody.tools
translatedBody.tools = normalizeClaudeToolsForDispatch(
translatedBody.tools,
provider
) as typeof translatedBody.tools;
}

View File

@@ -25,3 +25,41 @@ export function defaultClaudeToolType(tools: unknown): unknown {
return tool;
});
}
/**
* Strip the `type: "custom"` discriminator from Claude-format tools, leaving every
* other field (name, description, input_schema, …) untouched. AgentRouter's upstream
* (New-API, Rust serde) only accepts versioned tool types (`web_search_20250305`,
* `web_search_20260209`); plain tools must omit `type` entirely, so `type: "custom"` —
* whether client-declared (Claude Code v2.1+) or backfilled by defaultClaudeToolType()
* (#2195) — is a hard 400 "unknown variant `custom`" that crashes the client session.
* Versioned/built-in types are preserved; typeless entries stay typeless. Non-object
* entries pass through untouched (same rationale as defaultClaudeToolType).
*/
export function stripClaudeCustomToolType(tools: unknown): unknown {
if (!Array.isArray(tools)) return tools;
return tools.map((tool) => {
if (
tool &&
typeof tool === "object" &&
!Array.isArray(tool) &&
(tool as UnknownRecord).type === "custom"
) {
const { type: _stripped, ...rest } = tool as UnknownRecord;
return rest;
}
return tool;
});
}
/**
* Per-provider dispatch decision for Claude-format tool normalization. AgentRouter
* rejects `type: "custom"` (see stripClaudeCustomToolType) while strict gateways like
* MiniMax REQUIRE the explicit discriminator (#2195) — the two quirks are mutually
* exclusive, so the normalization is provider-scoped, never global.
*/
export function normalizeClaudeToolsForDispatch(tools: unknown, provider: string): unknown {
return provider === "agentrouter"
? stripClaudeCustomToolType(tools)
: defaultClaudeToolType(tools);
}

View File

@@ -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(

View File

@@ -11,9 +11,27 @@
import { saveCallLog } from "@/lib/usageDb";
import { sanitizeErrorMessage } from "../../utils/error.ts";
import { formatSearchProviderFailure } from "./providerFailure.ts";
import { HTTP_STATUS } from "../../config/constants.ts";
import { isSubscriptionQuotaText } from "../../services/quotaTextCooldowns.ts";
import type { SearchProviderConfig } from "../../config/searchRegistry.ts";
import type { SearchResult } from "../search.ts";
const SEARCH_COOLDOWN_STATUSES = new Set([
HTTP_STATUS.PAYMENT_REQUIRED,
HTTP_STATUS.REQUEST_TIMEOUT,
HTTP_STATUS.RATE_LIMITED,
HTTP_STATUS.PLAN_LIMIT_EXCEEDED,
HTTP_STATUS.SERVER_ERROR,
HTTP_STATUS.BAD_GATEWAY,
HTTP_STATUS.SERVICE_UNAVAILABLE,
HTTP_STATUS.GATEWAY_TIMEOUT,
]);
export function shouldCoolDownSearchConnection(status: number, errorText: string): boolean {
if (SEARCH_COOLDOWN_STATUSES.has(status)) return true;
return isSubscriptionQuotaText(errorText.toLowerCase());
}
/** Resolved proxy binding for a single provider attempt. */
export interface ResolvedSearchProxy {
proxy: unknown;
@@ -196,6 +214,14 @@ export async function executeProviderFetch(
if (log) {
log.error("SEARCH", `${config.id} error ${response.status}: ${errorText.slice(0, 200)}`);
}
if (connectionId && shouldCoolDownSearchConnection(response.status, errorText)) {
try {
const { markAccountUnavailable } = await import("@/sse/services/auth.ts");
await markAccountUnavailable(connectionId, response.status, errorText, config.id, null);
} catch {
/* non-critical - background cooldown mark must not break search response */
}
}
logCall({
status: response.status,
duration: Date.now() - startTime,

View File

@@ -35,6 +35,11 @@ function resolveVideoEndpoint(credentials: unknown, fallback: string): string {
? creds.baseUrl.trim()
: null;
const nodeBaseUrl = psdBaseUrl || topLevelBaseUrl;
// Узел своего адреса может не иметь — у встроенных провайдеров его и не
// бывает. Тогда работает `fallback`: это готовый endpoint из реестра, а не
// корень, поэтому путь к нему не дописывается (у nanogpt адрес оканчивается
// на /video/generations — в единственном числе).
if (!nodeBaseUrl) return fallback;
let n = nodeBaseUrl;
while (n.endsWith("/")) n = n.slice(0, -1);
if (n.endsWith("/videos/generations")) return n;

View File

@@ -77,6 +77,7 @@ import {
buildSubscriptionQuotaFallback,
buildWeeklyQuotaFallback,
buildSessionQuotaFallback,
SUBSCRIPTION_QUOTA_COOLDOWN_MS,
} from "./quotaTextCooldowns.ts";
import { parseDayGranularityResetMs, shouldPreserveQuotaSignals } from "./quotaResetParsing.ts";
import { evictLockoutOverflow } from "./accountFallback/lockoutEviction.ts";
@@ -1560,7 +1561,7 @@ export function classifyError(
if (status === HTTP_STATUS.UNAUTHORIZED || status === HTTP_STATUS.FORBIDDEN) {
return RateLimitReason.AUTH_ERROR;
}
if (status === HTTP_STATUS.PAYMENT_REQUIRED) {
if (status === HTTP_STATUS.PAYMENT_REQUIRED || status === HTTP_STATUS.PLAN_LIMIT_EXCEEDED) {
return RateLimitReason.QUOTA_EXHAUSTED;
}
if (status === HTTP_STATUS.RATE_LIMITED) {
@@ -2131,6 +2132,24 @@ export function checkFallbackError(
return buildRetryableFallback(RateLimitReason.SERVER_ERROR);
}
// 432 -- plan limit reached (e.g. Tavily, Context7, and search upstreams)
if (status === HTTP_STATUS.PLAN_LIMIT_EXCEEDED) {
const subResult = buildSubscriptionQuotaFallback(
errorStr,
() => getUpstreamRetryHint()?.retryAfterMs ?? null,
parseRetryFromErrorText,
provider
);
if (subResult) return subResult;
const cooldownMs = getUpstreamRetryHint()?.retryAfterMs ?? SUBSCRIPTION_QUOTA_COOLDOWN_MS;
return {
shouldFallback: true,
cooldownMs,
baseCooldownMs: cooldownMs,
reason: RateLimitReason.QUOTA_EXHAUSTED,
};
}
// 400 — context overflow / malformed request / model access denied
if (status === HTTP_STATUS.BAD_REQUEST) {
// Check structured error codes first (more reliable, no false positives)

View File

@@ -36,6 +36,11 @@ export function isSubscriptionQuotaText(lower: string, provider?: string | null)
lower.includes("claude pro usage limit") ||
lower.includes("you've reached your usage limit") ||
lower.includes("you have reached your usage limit") ||
lower.includes("exceeds your plan") ||
lower.includes("plan limit") ||
lower.includes("plan's set usage limit") ||
lower.includes("plan limit exceeded") ||
lower.includes("usage limit exceeded") ||
// Native Claude OAuth uses this otherwise-generic 429 wording for an
// exhausted subscription window. Keep it provider-scoped: other upstreams
// can use the same phrase for a short RPM throttle.
@@ -43,7 +48,7 @@ export function isSubscriptionQuotaText(lower: string, provider?: string | null)
);
}
const SUBSCRIPTION_QUOTA_COOLDOWN_MS = 60 * 60 * 1000; // 1 hour
export const SUBSCRIPTION_QUOTA_COOLDOWN_MS = 60 * 60 * 1000; // 1 hour
/**
* Builds the QUOTA_EXHAUSTED fallback for the subscription-quota text above.

View File

@@ -129,7 +129,7 @@ export function resolveCredentialHealthSweepInterval(
const parsed = parseInt(envVal, 10);
if (!isNaN(parsed) && parsed >= 10_000) return parsed;
}
return 300_000; // default 5 min
return 3_600_000; // default 60 min
}
/**
@@ -142,7 +142,7 @@ function getSweepInterval(): number {
const parsed = parseInt(envVal, 10);
if (!isNaN(parsed) && parsed >= 10_000) return parsed;
}
return 300_000; // default 5 min
return 3_600_000; // default 60 min
}
/**

View File

@@ -184,10 +184,11 @@ export const DEFAULT_RESILIENCE_SETTINGS: ResilienceSettings = {
// default until an operator adds an override here.
providerQuotaOverrides: {},
// Global default cadence for the background credential health check sweep.
// 5 minutes preserves the pre-setting scheduler default (300 000 ms);
// 0 disables the sweep entirely. Per-connection overrides always win.
// 60 minutes: the sweep makes a real upstream probe against EVERY active
// connection, so the previous 5-minute default cost 12 requests/hour per
// connection. 0 disables the sweep entirely. Per-connection overrides win.
credentialHealthCheck: {
intervalMinutes: 5,
intervalMinutes: 60,
},
};

View File

@@ -0,0 +1,107 @@
import test from "node:test";
import assert from "node:assert/strict";
// AgentRouter's upstream (New-API, Rust serde) only accepts versioned Claude tool
// types (web_search_20250305 / web_search_20260209); plain tools must omit `type`
// entirely. A tool carrying `type: "custom"` — whether client-declared (Claude
// Code v2.1+) or backfilled by defaultClaudeToolType() (#2195, MiniMax) — is a
// hard 400 "unknown variant `custom`" that crashes the client session.
// normalizeClaudeToolsForDispatch() routes per provider: agentrouter strips the
// custom discriminator, every other Claude-format target keeps the #2195 default.
const { normalizeClaudeToolsForDispatch } = await import(
"../../open-sse/handlers/chatCore/claudeToolDefaults.ts"
);
test("agentrouter: strips an explicit type:'custom' discriminator, preserving all other fields", () => {
const tools = [
{
type: "custom",
name: "get_weather",
description: "Get weather",
input_schema: { type: "object", properties: {} },
},
];
const out = normalizeClaudeToolsForDispatch(tools, "agentrouter") as Array<
Record<string, unknown>
>;
assert.equal(out[0].type, undefined, "type:'custom' must be removed");
assert.equal(out[0].name, "get_weather");
assert.equal(out[0].description, "Get weather");
assert.deepEqual(out[0].input_schema, { type: "object", properties: {} });
});
test("agentrouter: does NOT default a missing type (typeless tools stay typeless)", () => {
const tools = [{ name: "get_weather", description: "Get weather", input_schema: {} }];
const out = normalizeClaudeToolsForDispatch(tools, "agentrouter") as Array<
Record<string, unknown>
>;
assert.equal(out[0].type, undefined, "no type:'custom' may be backfilled for agentrouter");
});
test("agentrouter: preserves versioned/built-in tool types (only 'custom' is stripped)", () => {
const tools = [
{ type: "web_search_20260209", name: "web_search" },
{ type: "computer_20241022", name: "computer" },
{ type: "custom", name: "plain" },
{ name: "typeless" },
];
const out = normalizeClaudeToolsForDispatch(tools, "agentrouter") as Array<
Record<string, unknown>
>;
assert.equal(out[0].type, "web_search_20260209");
assert.equal(out[1].type, "computer_20241022");
assert.equal(out[2].type, undefined, "custom is stripped");
assert.equal(out[3].type, undefined, "typeless stays typeless");
});
test("non-agentrouter providers keep the #2195 behavior: missing type defaults to 'custom'", () => {
const tools = [{ name: "get_weather", input_schema: {} }];
for (const provider of ["minimax", "anthropic", "some-gateway"]) {
const out = normalizeClaudeToolsForDispatch(tools, provider) as Array<
Record<string, unknown>
>;
assert.equal(out[0].type, "custom", `${provider} must keep the MiniMax #2195 default`);
}
});
test("non-agentrouter providers leave an explicit type:'custom' untouched", () => {
const tools = [{ type: "custom", name: "a", input_schema: {} }];
const out = normalizeClaudeToolsForDispatch(tools, "minimax") as Array<
Record<string, unknown>
>;
assert.equal(out[0].type, "custom");
});
test("returns non-array input unchanged for any provider", () => {
assert.equal(normalizeClaudeToolsForDispatch(undefined, "agentrouter"), undefined);
assert.equal(normalizeClaudeToolsForDispatch(null, "agentrouter"), null);
const obj = { not: "an array" };
assert.equal(normalizeClaudeToolsForDispatch(obj, "agentrouter"), obj);
assert.equal(normalizeClaudeToolsForDispatch(obj, "minimax"), obj);
});
test("does not mutate the original tool objects", () => {
const explicit = { type: "custom", name: "x", input_schema: {} };
const typeless = { name: "y", input_schema: {} };
const out = normalizeClaudeToolsForDispatch([explicit, typeless], "agentrouter") as Array<
Record<string, unknown>
>;
assert.equal(explicit.type, "custom", "original explicit tool must stay untouched");
assert.equal(typeless.type, undefined, "original typeless tool must stay untouched");
assert.equal(out[0].type, undefined);
});
test("passes non-object array entries through unchanged (no garbage wrapping)", () => {
const tools = [
{ type: "custom", name: "real_tool", input_schema: {} }, // object → stripped
null,
"weird",
42,
];
const out = normalizeClaudeToolsForDispatch(tools, "agentrouter") as unknown[];
assert.equal((out[0] as Record<string, unknown>).type, undefined, "real object gets stripped");
assert.equal(out[1], null, "null passes through unchanged");
assert.equal(out[2], "weird", "string passes through unchanged");
assert.equal(out[3], 42, "number passes through unchanged");
});

View 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,
"короткая очередь не должна приводить к раннему отказу"
);
});

View File

@@ -4,6 +4,23 @@ import fs from "node:fs";
import os from "node:os";
import path from "node:path";
// Typed views over the raw sqlite rows / payload envelopes this suite inspects —
// keeps the assertions honest without @typescript-eslint/no-explicit-any (#12146).
type CallLogRow = {
name?: string;
cnt?: number;
detail_state?: string | null;
cache_source?: string | null;
has_request_body?: number;
has_response_body?: number;
has_pipeline_details?: number;
artifact_relpath?: string;
artifact_size_bytes?: number;
error_summary?: string | null;
};
type PayloadEnvelope = { body?: Record<string, unknown> };
type PayloadMap = { providerResponse?: PayloadEnvelope; clientResponse?: PayloadEnvelope };
import { useDecollidedMigrationsDir } from "./helpers/decollidedMigrationsDir.ts";
useDecollidedMigrationsDir();
@@ -133,9 +150,18 @@ test("saveCallLog stores only summary metadata in SQLite and writes detailed art
assert.equal(detail?.comboStepId, "step-openai-a");
assert.equal(detail?.comboExecutionKey, "combo-a:0:step-openai-a");
assert.equal(detail?.pipelinePayloads?.clientRawRequest?.body?.raw, true);
assert.equal((detail?.pipelinePayloads?.providerRequest as any).body?.translated, true);
assert.equal((detail?.pipelinePayloads as any).providerResponse?.body?.upstream, true);
assert.equal((detail?.pipelinePayloads as any).clientResponse?.body?.final, true);
assert.equal(
(detail?.pipelinePayloads?.providerRequest as PayloadEnvelope | undefined)?.body?.translated,
true
);
assert.equal(
(detail?.pipelinePayloads as PayloadMap | undefined)?.providerResponse?.body?.upstream,
true
);
assert.equal(
(detail?.pipelinePayloads as PayloadMap | undefined)?.clientResponse?.body?.final,
true
);
assert.match(
detail?.artifactRelPath || "",
/^2026-03-30\/2026-03-30T12-34-56\.789Z_req_artifact_1\.json$/
@@ -145,7 +171,7 @@ test("saveCallLog stores only summary metadata in SQLite and writes detailed art
const columns = db
.prepare("SELECT name FROM pragma_table_info('call_logs') ORDER BY cid")
.all()
.map((row) => (row as any).name);
.map((row) => (row as CallLogRow).name);
assert.equal(columns.includes("request_body"), false);
assert.equal(columns.includes("response_body"), false);
assert.equal(columns.includes("error"), false);
@@ -158,12 +184,12 @@ test("saveCallLog stores only summary metadata in SQLite and writes detailed art
`
)
.get(logId);
(assert as any).equal((summaryRow as any).detail_state, "ready");
assert.equal((summaryRow as any).cache_source, "semantic");
assert.equal((summaryRow as any).has_request_body, 1);
assert.equal((summaryRow as any).has_response_body, 1);
assert.equal((summaryRow as any).has_pipeline_details, 1);
assert.equal(typeof (summaryRow as any).artifact_relpath, "string");
assert.equal((summaryRow as CallLogRow).detail_state, "ready");
assert.equal((summaryRow as CallLogRow).cache_source, "semantic");
assert.equal((summaryRow as CallLogRow).has_request_body, 1);
assert.equal((summaryRow as CallLogRow).has_response_body, 1);
assert.equal((summaryRow as CallLogRow).has_pipeline_details, 1);
assert.equal(typeof (summaryRow as CallLogRow).artifact_relpath, "string");
const artifactPath = path.join(TEST_DATA_DIR, "call_logs", detail.artifactRelPath);
const serializedArtifact = fs.readFileSync(artifactPath, "utf8");
@@ -241,14 +267,18 @@ test("rotateCallLogs removes expired rows and orphaned artifacts but keeps fresh
.getDbInstance()
.prepare("SELECT artifact_relpath FROM call_logs WHERE id = ?")
.get("fresh-log");
const freshAbsPath = path.join(TEST_DATA_DIR, "call_logs", (freshRow as any).artifact_relpath);
const freshAbsPath = path.join(
TEST_DATA_DIR,
"call_logs",
(freshRow as CallLogRow).artifact_relpath
);
assert.equal(
(
core
.getDbInstance()
.prepare("SELECT COUNT(*) AS cnt FROM call_logs WHERE id = ?")
.get("expired-log") as any
.get("expired-log") as CallLogRow
).cnt,
1
);
@@ -259,13 +289,20 @@ test("rotateCallLogs removes expired rows and orphaned artifacts but keeps fresh
const db = core.getDbInstance();
assert.equal(
(db.prepare("SELECT COUNT(*) AS cnt FROM call_logs WHERE id = ?").get("expired-log") as any)
.cnt,
(
db
.prepare("SELECT COUNT(*) AS cnt FROM call_logs WHERE id = ?")
.get("expired-log") as CallLogRow
).cnt,
0
);
assert.equal(fs.existsSync(oldAbsPath), false);
assert.equal(
(db.prepare("SELECT COUNT(*) AS cnt FROM call_logs WHERE id = ?").get("fresh-log") as any).cnt,
(
db
.prepare("SELECT COUNT(*) AS cnt FROM call_logs WHERE id = ?")
.get("fresh-log") as CallLogRow
).cnt,
1
);
assert.equal(fs.existsSync(freshAbsPath), true);
@@ -424,15 +461,15 @@ test("getCallLogById falls back to legacy inline rows and request_detail_logs",
assert.deepEqual(detail?.error, { message: "legacy-error" });
assert.equal(detail?.pipelinePayloads?.clientRequest?.body?.from, "detail-client");
assert.equal(
(detail?.pipelinePayloads?.providerRequest as any).body?.from,
(detail?.pipelinePayloads?.providerRequest as PayloadEnvelope | undefined)?.body?.from,
"detail-provider-request"
);
(assert as any).equal(
(detail?.pipelinePayloads?.providerResponse as any).body?.from,
assert.equal(
(detail?.pipelinePayloads?.providerResponse as PayloadEnvelope | undefined)?.body?.from,
"detail-provider-response"
);
assert.equal(
(detail?.pipelinePayloads?.clientResponse as any).body?.from,
(detail?.pipelinePayloads?.clientResponse as PayloadEnvelope | undefined)?.body?.from,
"detail-client-response"
);
assert.equal(detail?.hasPipelineDetails, true);
@@ -461,8 +498,8 @@ test("getCallLogById marks missing artifacts explicitly and clears stale DB poin
const row = db
.prepare("SELECT artifact_relpath, detail_state FROM call_logs WHERE id = ?")
.get("missing-artifact");
assert.equal((row as any).artifact_relpath, null);
assert.equal((row as any).detail_state, "missing");
assert.equal((row as CallLogRow).artifact_relpath, null);
assert.equal((row as CallLogRow).detail_state, "missing");
});
test("saveCallLog keeps large payloads out of SQLite while preserving explicit detail export", async () => {
@@ -491,12 +528,12 @@ test("saveCallLog keeps large payloads out of SQLite while preserving explicit d
`
)
.get("artifact-only-large-payload");
assert.equal((row as any).detail_state, "ready");
assert.equal((row as any).has_request_body, 1);
(assert as any).equal(typeof (row as any).artifact_relpath, "string");
assert.equal((row as any).error_summary, "upstream unavailable");
assert.equal((row as CallLogRow).detail_state, "ready");
assert.equal((row as CallLogRow).has_request_body, 1);
assert.equal(typeof (row as CallLogRow).artifact_relpath, "string");
assert.equal((row as CallLogRow).error_summary, "upstream unavailable");
const artifactPath = path.join(TEST_DATA_DIR, "call_logs", (row as any).artifact_relpath);
const artifactPath = path.join(TEST_DATA_DIR, "call_logs", (row as CallLogRow).artifact_relpath);
const artifact = JSON.parse(fs.readFileSync(artifactPath, "utf8"));
assert.equal(artifact.requestBody.payload.length, requestBody.payload.length);
@@ -505,7 +542,10 @@ test("saveCallLog keeps large payloads out of SQLite while preserving explicit d
const exported = await callLogs.exportCallLogsSince("2026-03-31T00:00:00.000Z");
assert.equal(exported.length, 1);
assert.equal((exported[0] as any).requestBody.payload.length, requestBody.payload.length);
assert.equal(
(exported[0] as { requestBody: { payload: string } }).requestBody.payload.length,
requestBody.payload.length
);
});
test("saveCallLog truncates oversized call log artifacts for storage", async () => {
@@ -539,10 +579,10 @@ test("saveCallLog truncates oversized call log artifacts for storage", async ()
`
)
.get("truncated-artifact");
assert.equal((row as any).detail_state, "ready");
assert.ok((row as any).artifact_size_bytes <= 512 * 1024);
assert.equal((row as CallLogRow).detail_state, "ready");
assert.ok((row as CallLogRow).artifact_size_bytes <= 512 * 1024);
const artifactPath = path.join(TEST_DATA_DIR, "call_logs", (row as any).artifact_relpath);
const artifactPath = path.join(TEST_DATA_DIR, "call_logs", (row as CallLogRow).artifact_relpath);
const artifact = JSON.parse(fs.readFileSync(artifactPath, "utf8"));
assert.deepEqual(artifact.requestBody, { payload: "request" });
assert.deepEqual(artifact.responseBody, { output: "response" });
@@ -582,10 +622,10 @@ test("saveCallLog omits oversized non-stream pipeline payloads to enforce artifa
`
)
.get("truncated-pipeline-artifact");
assert.equal((row as any).detail_state, "ready");
assert.ok((row as any).artifact_size_bytes <= 512 * 1024);
assert.equal((row as CallLogRow).detail_state, "ready");
assert.ok((row as CallLogRow).artifact_size_bytes <= 512 * 1024);
const artifactPath = path.join(TEST_DATA_DIR, "call_logs", (row as any).artifact_relpath);
const artifactPath = path.join(TEST_DATA_DIR, "call_logs", (row as CallLogRow).artifact_relpath);
const artifact = JSON.parse(fs.readFileSync(artifactPath, "utf8"));
assert.deepEqual(artifact.requestBody, { payload: "request" });
assert.deepEqual(artifact.responseBody, { output: "response" });
@@ -626,10 +666,10 @@ test("saveCallLog honors CALL_LOG_PIPELINE_MAX_SIZE_KB for pipeline artifacts",
`
)
.get("configured-pipeline-artifact-cap");
assert.equal((row as any).detail_state, "ready");
assert.ok((row as any).artifact_size_bytes <= 8 * 1024);
assert.equal((row as CallLogRow).detail_state, "ready");
assert.ok((row as CallLogRow).artifact_size_bytes <= 8 * 1024);
const artifactPath = path.join(TEST_DATA_DIR, "call_logs", (row as any).artifact_relpath);
const artifactPath = path.join(TEST_DATA_DIR, "call_logs", (row as CallLogRow).artifact_relpath);
const artifact = JSON.parse(fs.readFileSync(artifactPath, "utf8"));
assert.deepEqual(artifact.pipeline, {
error: {
@@ -668,10 +708,10 @@ test("saveCallLog falls back to a compact sentinel when the configured cap is ve
`
)
.get("tiny-pipeline-artifact-cap");
assert.equal((row as any).detail_state, "ready");
assert.ok((row as any).artifact_size_bytes <= 1024);
assert.equal((row as CallLogRow).detail_state, "ready");
assert.ok((row as CallLogRow).artifact_size_bytes <= 1024);
const artifactPath = path.join(TEST_DATA_DIR, "call_logs", (row as any).artifact_relpath);
const artifactPath = path.join(TEST_DATA_DIR, "call_logs", (row as CallLogRow).artifact_relpath);
const artifact = JSON.parse(fs.readFileSync(artifactPath, "utf8"));
assert.deepEqual(artifact, {
schemaVersion: 5,
@@ -716,9 +756,9 @@ test("saveCallLog preserves a truncated error in size-limit-fallback artifacts (
`
)
.get("tiny-cap-preserves-error");
assert.equal((row as any).detail_state, "ready");
assert.equal((row as CallLogRow).detail_state, "ready");
const artifactPath = path.join(TEST_DATA_DIR, "call_logs", (row as any).artifact_relpath);
const artifactPath = path.join(TEST_DATA_DIR, "call_logs", (row as CallLogRow).artifact_relpath);
const artifact = JSON.parse(fs.readFileSync(artifactPath, "utf8"));
assert.equal(
artifact.error,
@@ -758,10 +798,10 @@ test("CALL_LOG_PIPELINE_MAX_SIZE_KB does not cap artifacts without pipeline deta
`
)
.get("non-pipeline-artifact-ignores-pipeline-cap");
assert.equal((row as any).detail_state, "ready");
assert.ok((row as any).artifact_size_bytes > 8 * 1024);
assert.equal((row as CallLogRow).detail_state, "ready");
assert.ok((row as CallLogRow).artifact_size_bytes > 8 * 1024);
const artifactPath = path.join(TEST_DATA_DIR, "call_logs", (row as any).artifact_relpath);
const artifactPath = path.join(TEST_DATA_DIR, "call_logs", (row as CallLogRow).artifact_relpath);
const artifact = JSON.parse(fs.readFileSync(artifactPath, "utf8"));
assert.equal(artifact.requestBody.payload.length, requestBody.payload.length);
});

View File

@@ -0,0 +1,167 @@
/**
* RED regression coverage for issue #12073: Node 26 deprecates
* module.register() in favor of the synchronous module.registerHooks() API.
*/
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, it } from "node:test";
import { fileURLToPath, pathToFileURL } from "node:url";
import { parseNodeVersion } from "../../../src/shared/utils/nodeRuntimeSupport.ts";
import { registerAliasResolver, resolveAlias } from "../../../bin/aliasResolver.mjs";
const __dirname = fileURLToPath(new URL(".", import.meta.url));
const REPO_ROOT = join(__dirname, "..", "..", "..");
const NODE_VERSION = parseNodeVersion(process.versions.node);
const NODE_26_SKIP_REASON =
`running Node ${process.versions.node}; DEP0205 assertion is unverified on this runtime ` +
"(requires Node >= 26)";
// Child import() specifiers must be real file URLs. A Windows drive letter in
// a bare path would otherwise be parsed as a URL scheme.
const repoFileUrl = (relPath: string) => pathToFileURL(join(REPO_ROOT, relPath)).href;
function runChild(script: string, cwd = REPO_ROOT) {
const result = spawnSync(process.execPath, ["--input-type=module", "-e", script], {
cwd,
env: {
...process.env,
DATA_DIR: mkdtempSync(join(tmpdir(), "alias-resolver-12073-")),
OMNIROUTE_CLI_SKIP_REPO_ENV: "1",
},
encoding: "utf8",
});
return { stdout: result.stdout, stderr: result.stderr, status: result.status };
}
describe("aliasResolver Node 26 registration (#12073)", () => {
it(
"uses the real entry point without emitting DEP0205",
{ skip: NODE_VERSION.major >= 26 ? false : NODE_26_SKIP_REASON },
() => {
const script = `
await import("tsx/esm");
const { registerAliasResolver } = await import(${JSON.stringify(repoFileUrl("bin/aliasResolver.mjs"))});
const ok = await registerAliasResolver(${JSON.stringify(REPO_ROOT)});
if (!ok) { console.error("FAIL: registerAliasResolver returned false"); process.exit(2); }
try {
const m = await import(${JSON.stringify(repoFileUrl("src/shared/network/outboundUrlGuard.ts"))});
console.log("OK:" + Object.keys(m).sort().join(","));
} catch (err) {
console.error("FAIL:" + (err && err.message || err));
process.exit(3);
}
`;
const { stdout, stderr, status } = runChild(script);
assert.equal(status, 0, `expected exit 0, got ${status}. stderr=${stderr.slice(0, 500)}`);
assert.match(stdout.trim(), /^OK:/);
assert.doesNotMatch(stderr, /DEP0205|DeprecationWarning/);
}
);
it("keeps global-install-style alias imports working", () => {
// A foreign cwd has no repository tsconfig or package.json to let tsx
// resolve the bare alias by itself. This makes the import a tripwire for a
// hook that registers without throwing but silently never runs.
const globalInstallCwd = mkdtempSync(join(tmpdir(), "alias-resolver-global-install-"));
try {
const script = `
await import(${JSON.stringify(repoFileUrl("node_modules/tsx/dist/esm/index.mjs"))});
const { registerAliasResolver } = await import(${JSON.stringify(repoFileUrl("bin/aliasResolver.mjs"))});
const ok = await registerAliasResolver(${JSON.stringify(REPO_ROOT)});
if (!ok) { console.error("FAIL: registerAliasResolver returned false"); process.exit(2); }
try {
const m = await import("@/shared/network/outboundUrlGuard");
console.log("OK:" + Object.keys(m).sort().join(","));
} catch (err) {
console.error("FAIL:" + (err && err.message || err));
process.exit(3);
}
`;
const { stdout, stderr, status } = runChild(script, globalInstallCwd);
assert.equal(status, 0, `expected exit 0, got ${status}. stderr=${stderr.slice(0, 500)}`);
const trimmed = stdout.trim();
assert.match(trimmed, /^OK:/, `expected OK:<exports>, got: ${trimmed}`);
assert.match(trimmed, /OutboundUrlGuardError|PROVIDER_URL_BLOCKED_MESSAGE/);
} finally {
rmSync(globalInstallCwd, {
recursive: true,
force: true,
maxRetries: 5,
retryDelay: 100,
});
}
});
it("retains module.register() for simulated legacy runtimes", () => {
const script = `
await import("tsx/esm");
const { createRequire, syncBuiltinESMExports } = await import("node:module");
const require = createRequire(import.meta.url);
const cjsModule = require("node:module");
const saved = cjsModule.registerHooks;
try {
cjsModule.registerHooks = undefined;
syncBuiltinESMExports();
const esmModule = await import("node:module");
if (esmModule.registerHooks !== undefined) {
console.error("FAIL: registerHooks was not blanked");
process.exitCode = 2;
} else {
const { registerAliasResolver } = await import(${JSON.stringify(repoFileUrl("bin/aliasResolver.mjs"))});
const ok = await registerAliasResolver(${JSON.stringify(REPO_ROOT)});
const m = await import(${JSON.stringify(repoFileUrl("src/shared/network/outboundUrlGuard.ts"))});
const hasExpectedExport = "OutboundUrlGuardError" in m || "PROVIDER_URL_BLOCKED_MESSAGE" in m;
if (!ok || !hasExpectedExport) {
console.error("FAIL: legacy registration did not resolve the alias");
process.exitCode = 3;
} else {
console.log("OK:true:" + Object.keys(m).sort().join(","));
}
}
} catch (err) {
console.error("FAIL:" + (err && err.message || err));
process.exitCode = 4;
} finally {
cjsModule.registerHooks = saved;
syncBuiltinESMExports();
}
`;
const { stdout, stderr, status } = runChild(script);
assert.equal(status, 0, `expected exit 0, got ${status}. stderr=${stderr.slice(0, 500)}`);
assert.match(stdout.trim(), /^OK:true:/);
if (NODE_VERSION.major >= 26) {
assert.match(stderr, /DEP0205|DeprecationWarning/);
}
});
it("keeps idempotency, input validation, and traversal guards characterized", async () => {
const noSrcRoot = mkdtempSync(join(tmpdir(), "alias-resolver-no-src-"));
const emptySrcRoot = mkdtempSync(join(tmpdir(), "alias-resolver-empty-src-"));
try {
assert.equal(await registerAliasResolver(noSrcRoot), false);
mkdirSync(join(emptySrcRoot, "src"), { recursive: true });
assert.equal(await registerAliasResolver(emptySrcRoot), true);
assert.equal(await registerAliasResolver(emptySrcRoot), true);
await assert.rejects(() => registerAliasResolver(""), TypeError);
await assert.rejects(() => registerAliasResolver(null), TypeError);
await assert.rejects(() => registerAliasResolver(123), TypeError);
assert.equal(resolveAlias("@/../../../etc/hostname", REPO_ROOT), null);
assert.equal(resolveAlias("@omniroute/open-sse/../../etc/passwd", REPO_ROOT), null);
} finally {
rmSync(noSrcRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
rmSync(emptySrcRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
}
});
});

View File

@@ -20,13 +20,13 @@ function withEnv(value: string | undefined, fn: () => void) {
}
}
test("default resilience settings include a 5-minute credential health check cadence", () => {
assert.equal(DEFAULT_RESILIENCE_SETTINGS.credentialHealthCheck.intervalMinutes, 5);
test("default resilience settings include a 60-minute credential health check cadence", () => {
assert.equal(DEFAULT_RESILIENCE_SETTINGS.credentialHealthCheck.intervalMinutes, 60);
});
test("resolveResilienceSettings returns the default interval when nothing is stored", () => {
const resolved = resolveResilienceSettings({});
assert.equal(resolved.credentialHealthCheck.intervalMinutes, 5);
assert.equal(resolved.credentialHealthCheck.intervalMinutes, 60);
});
test("mergeResilienceSettings stores an operator interval and preserves other sections", () => {
@@ -45,9 +45,9 @@ test("mergeResilienceSettings clamps the interval into the 0-1440 band", () => {
assert.equal(high.credentialHealthCheck.intervalMinutes, 1440);
});
test("sweep interval: no operator setting and no env → built-in 5 min default", () => {
test("sweep interval: no operator setting and no env → built-in 60 min default", () => {
withEnv(undefined, () => {
assert.equal(resolveCredentialHealthSweepInterval({}), 300_000);
assert.equal(resolveCredentialHealthSweepInterval({}), 60 * 60_000);
});
});
@@ -87,6 +87,6 @@ test("sweep interval: non-numeric stored interval falls back to env/default", ()
const settings = {
resilienceSettings: { credentialHealthCheck: { intervalMinutes: "abc" } },
};
assert.equal(resolveCredentialHealthSweepInterval(settings), 300_000);
assert.equal(resolveCredentialHealthSweepInterval(settings), 60 * 60_000);
});
});

View File

@@ -0,0 +1,288 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import http from "node:http";
interface TestConnectionRecord {
id?: string | number;
isActive?: boolean;
testStatus?: string;
rateLimitedUntil?: string | null;
}
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-test-search-432-"));
process.env.DATA_DIR = TEST_DATA_DIR;
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "search-432-test-secret";
process.env.DISABLE_SQLITE_AUTO_BACKUP = "true";
const core = await import("../../src/lib/db/core.ts");
const accountFallback = await import("../../open-sse/services/accountFallback.ts");
const { RateLimitReason } = await import("../../open-sse/config/constants.ts");
const quotaTextCooldowns = await import("../../open-sse/services/quotaTextCooldowns.ts");
const providersDb = await import("../../src/lib/db/providers.ts");
const auth = await import("../../src/sse/services/auth.ts");
const connectionRecovery = await import("../../src/lib/quota/connectionRecovery.ts");
const searchProxy = await import("../../open-sse/handlers/search/searchProxy.ts");
const { closeCallLogSaves } = await import("../../src/lib/usage/callLogs.ts");
test.after(async () => {
await closeCallLogSaves(500).catch(() => {});
try {
core.resetDbInstance();
} catch {}
try {
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
} catch {}
});
test("Tavily 432 plan limit body is detected as transient plan usage limit", () => {
const tavily432Body = JSON.stringify({
error: "This request exceeds your plan's set usage limit. Please upgrade your plan or contact support@tavily.com",
});
const isMatched = quotaTextCooldowns.isSubscriptionQuotaText(tavily432Body.toLowerCase(), "tavily-search");
assert.equal(isMatched, true, "Should recognize Tavily plan limit error message");
});
test("checkFallbackError classifies status 432 and plan limit text as non-permanent quota_exhausted", () => {
const tavily432Body = JSON.stringify({
error: "This request exceeds your plan's set usage limit. Please upgrade your plan or contact support@tavily.com",
});
const result = accountFallback.checkFallbackError(
432,
tavily432Body,
"tavily-search",
null,
undefined,
undefined,
0
);
assert.equal(result.shouldFallback, true);
assert.equal(result.reason, RateLimitReason.QUOTA_EXHAUSTED);
assert.equal(result.permanent, undefined);
assert.equal(result.creditsExhausted, undefined);
assert.ok(result.cooldownMs > 0, "Should have a positive cooldown duration");
});
test("markAccountUnavailable sets transient unavailable status without deactivating the connection", async () => {
const conn = await providersDb.createProviderConnection({
provider: "tavily-search",
authType: "apikey",
name: "tavily-plan-limit-test",
apiKey: "tvly-test-key-12345",
isActive: true,
testStatus: "active",
});
const connId = String(conn.id);
const errorText = JSON.stringify({
error: "This request exceeds your plan's set usage limit.",
});
await auth.markAccountUnavailable(connId, 432, errorText, "tavily-search", null);
const updatedRaw = (await providersDb.getProviderConnections({
provider: "tavily-search",
})) as TestConnectionRecord[];
const updated = (Array.isArray(updatedRaw) ? updatedRaw : []).find(
(c) => String(c.id) === connId
);
assert.ok(updated, "Connection should exist in DB");
assert.equal(updated.isActive, true, "Connection must remain isActive=1");
assert.equal(updated.testStatus, "unavailable", "Connection should be marked transient unavailable");
assert.ok(updated.rateLimitedUntil, "rateLimitedUntil must be populated");
const untilMs = new Date(updated.rateLimitedUntil).getTime();
assert.ok(untilMs > Date.now(), "rateLimitedUntil should be in the future");
});
test("executeProviderFetch calls markAccountUnavailable on 432 error response when connectionId is present", async () => {
let serverPort = 0;
const server = http.createServer((_req, res) => {
res.writeHead(432, { "Content-Type": "application/json", Connection: "close" });
res.end(JSON.stringify({
error: "This request exceeds your plan's set usage limit. Please upgrade your plan or contact support@tavily.com",
}));
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", () => {
const addr = server.address();
if (addr && typeof addr === "object") serverPort = addr.port;
resolve();
});
});
const conn = await providersDb.createProviderConnection({
provider: "tavily-search",
authType: "apikey",
name: "tavily-fetch-432-test",
apiKey: "tvly-test-key-fetch-432",
isActive: true,
testStatus: "active",
});
const connId = String(conn.id);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
timer.unref?.();
try {
const { SEARCH_PROVIDERS } = await import("../../open-sse/config/searchRegistry.ts");
const result = await searchProxy.executeProviderFetch({
config: SEARCH_PROVIDERS["tavily-search"],
url: `http://127.0.0.1:${serverPort}/search`,
init: { method: "POST", headers: { "Content-Type": "application/json" } },
controller,
timer,
query: "test query",
searchType: "web",
maxResults: 5,
startTime: Date.now(),
connectionId: connId,
proxy: null,
proxyLevel: "none",
normalize: () => ({ results: [], totalResults: 0 }),
});
assert.equal(result.success, false);
assert.equal(result.status, 432);
const updatedRaw = (await providersDb.getProviderConnections({
provider: "tavily-search",
})) as TestConnectionRecord[];
const updated = (Array.isArray(updatedRaw) ? updatedRaw : []).find(
(c) => String(c.id) === connId
);
assert.ok(updated);
assert.equal(updated.isActive, true, "isActive should stay true");
assert.equal(updated.testStatus, "unavailable", "Connection should become unavailable");
assert.ok(updated.rateLimitedUntil, "rateLimitedUntil should be set");
} finally {
clearTimeout(timer);
await new Promise<void>((resolve) => {
server.closeAllConnections?.();
server.close(() => resolve());
});
}
});
test("executeProviderFetch does NOT mark account unavailable on non-quota client errors (400, 401, 403, 404)", async () => {
for (const statusCode of [400, 401, 403, 404]) {
let serverPort = 0;
const server = http.createServer((_req, res) => {
res.writeHead(statusCode, { "Content-Type": "application/json", Connection: "close" });
res.end(JSON.stringify({ error: `Generic client error ${statusCode}` }));
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", () => {
const addr = server.address();
if (addr && typeof addr === "object") serverPort = addr.port;
resolve();
});
});
const conn = await providersDb.createProviderConnection({
provider: "tavily-search",
authType: "apikey",
name: `tavily-fetch-${statusCode}-test`,
apiKey: `tvly-test-key-fetch-${statusCode}`,
isActive: true,
testStatus: "active",
});
const connId = String(conn.id);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
timer.unref?.();
try {
const { SEARCH_PROVIDERS } = await import("../../open-sse/config/searchRegistry.ts");
const result = await searchProxy.executeProviderFetch({
config: SEARCH_PROVIDERS["tavily-search"],
url: `http://127.0.0.1:${serverPort}/search`,
init: { method: "POST", headers: { "Content-Type": "application/json" } },
controller,
timer,
query: "test query",
searchType: "web",
maxResults: 5,
startTime: Date.now(),
connectionId: connId,
proxy: null,
proxyLevel: "none",
normalize: () => ({ results: [], totalResults: 0 }),
});
assert.equal(result.success, false);
assert.equal(result.status, statusCode);
const updatedRaw = (await providersDb.getProviderConnections({
provider: "tavily-search",
})) as TestConnectionRecord[];
const updated = (Array.isArray(updatedRaw) ? updatedRaw : []).find(
(c) => String(c.id) === connId
);
assert.ok(updated);
assert.equal(updated.isActive, true, `isActive must remain true on ${statusCode}`);
assert.equal(updated.testStatus, "active", `testStatus must remain 'active' on ${statusCode}`);
assert.ok(!updated.rateLimitedUntil, `rateLimitedUntil must be null/empty on ${statusCode}`);
} finally {
clearTimeout(timer);
await new Promise<void>((resolve) => {
server.closeAllConnections?.();
server.close(() => resolve());
});
}
}
});
test("cooldown on key1 allows getProviderCredentials to auto-rotate to healthy key2", async () => {
const conn1 = await providersDb.createProviderConnection({
provider: "tavily-search",
authType: "apikey",
name: "tavily-key-1",
apiKey: "tvly-key-1",
priority: 1,
isActive: true,
testStatus: "active",
});
const conn2 = await providersDb.createProviderConnection({
provider: "tavily-search",
authType: "apikey",
name: "tavily-key-2",
apiKey: "tvly-key-2",
priority: 2,
isActive: true,
testStatus: "active",
});
// Mark key1 as unavailable due to 432
await auth.markAccountUnavailable(String(conn1.id), 432, "plan limit reached", "tavily-search", null);
// Next credential resolution should skip key1 and return key2
const selected = await auth.getProviderCredentials("tavily-search");
assert.ok(selected, "Should return available credentials");
assert.equal(String(selected.connectionId), String(conn2.id), "Should rotate to healthy key2");
});
test("connectionRecovery restores elapsed unavailable connections", () => {
const pastTime = new Date(Date.now() - 5000).toISOString();
const connInput = {
id: "test-conn-1",
testStatus: "unavailable",
rateLimitedUntil: pastTime,
lastErrorAt: pastTime,
};
const isRecoverable = connectionRecovery.isRecoverableCooldownConnection(connInput, Date.now());
assert.equal(isRecoverable, true, "Elapsed unavailable connection should be recoverable");
});

View File

@@ -0,0 +1,73 @@
import test from "node:test";
import assert from "node:assert/strict";
/**
* `resolveVideoEndpoint` принимает запасной адрес и обязан им пользоваться.
*
* Функция объявлена как `(credentials, fallback)`, и вызывающий передаёт
* `providerConfig.baseUrl` — адрес из реестра. Но пока адрес брался только из
* учётных данных: у встроенного провайдера, где узел своего baseUrl не хранит,
* получался `null.endsWith(...)` и маршрут отвечал 500 с пустым телом.
*
* Дефект был не виден, потому что единственный встроенный провайдер формата
* `openai-video` (nanogpt) до этой ветки не доходил — его формат в реестре был
* записан как `openai`, и диспетчер отбрасывал его раньше (400 Unsupported
* video format). Живая проверка 2026-08-30: как только имя формата исправили,
* тот же запрос дал 500 и стек с `Cannot read properties of null`.
*
* Адрес из реестра — готовый endpoint, а не корень узла: у nanogpt это
* `/api/v1/video/generations` (единственное число), и дописывать к нему
* `/videos/generations` нельзя.
*/
const { handleOpenAIVideoGeneration } =
await import("../../open-sse/handlers/videoGeneration/openai.ts");
const REGISTRY_ENDPOINT = "https://nano-gpt.com/api/v1/video/generations";
const originalFetch = globalThis.fetch;
test.afterEach(() => {
globalThis.fetch = originalFetch;
});
function captureRequestUrl() {
const seen: string[] = [];
globalThis.fetch = (async (input: RequestInfo | URL) => {
seen.push(typeof input === "string" ? input : input.toString());
return new Response(JSON.stringify({ data: [{ url: "https://example.test/out.mp4" }] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}) as typeof fetch;
return seen;
}
test("falls back to the registry endpoint when the node carries no baseUrl", async () => {
const seen = captureRequestUrl();
const result = await handleOpenAIVideoGeneration({
model: "default",
provider: "nanogpt",
providerConfig: { baseUrl: REGISTRY_ENDPOINT, authHeader: "bearer" },
body: { prompt: "hello world" },
credentials: { apiKey: "test-key" },
});
assert.notEqual(result, undefined, "обработчик обязан вернуть результат, а не упасть");
assert.deepEqual(seen, [REGISTRY_ENDPOINT]);
});
test("still prefers the node's own baseUrl and appends the OpenAI path", async () => {
const seen = captureRequestUrl();
await handleOpenAIVideoGeneration({
model: "default",
provider: "custom-node",
providerConfig: { baseUrl: REGISTRY_ENDPOINT, authHeader: "bearer" },
body: { prompt: "hello world" },
credentials: { apiKey: "test-key", baseUrl: "https://node.test/v1/" },
});
assert.deepEqual(seen, ["https://node.test/v1/videos/generations"]);
});

View File

@@ -0,0 +1,86 @@
import test from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
/**
* Страж соответствия реестра и диспетчера видео.
*
* `GET /v1/videos/generations` рекламирует всё, что лежит в VIDEO_PROVIDERS без
* пометки `unsupported`. Диспетчер `handleVideoGeneration` умеет меньше: он
* разбирает `providerConfig.format` цепочкой ветвлений плюс job-пресетами, а на
* незнакомом формате отвечает `Unsupported video format`. Пока списки
* расходятся, каталог обещает модели, которые исполнитель гарантированно
* отвергает с 400 — независимо от ключей и баланса. Живая проверка 2026-08-30:
* `minimax/MiniMax-Hailuo-02`, `pollinations/default` и `nanogpt/default`
* отдавали ровно этот 400, будучи в выдаче каталога.
*
* Разбор идёт по тексту диспетчера, а не вызовом: неизвестный формат виден до
* первого сетевого запроса, а живой вызов каждого провайдера в юнит-тесте либо
* уходит в сеть, либо виснет на ретраях. Цена — тест надо поправить, если
* цепочку `format === "..."` заменят на другую конструкцию; тогда счётчик
* распознанных форматов упадёт до нуля и страж ниже скажет об этом прямо.
*/
const HERE = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(HERE, "../..");
const { VIDEO_PROVIDERS } = await import("../../open-sse/config/videoRegistry.ts");
function readSource(relativePath: string) {
return fs.readFileSync(path.join(REPO_ROOT, relativePath), "utf8");
}
/** Форматы, для которых у диспетчера есть собственная ветка. */
function branchFormats() {
const source = readSource("open-sse/handlers/videoGeneration.ts");
return new Set(
[...source.matchAll(/providerConfig\.format === "([a-z0-9-]+)"/g)].map((match) => match[1])
);
}
/** Форматы, которые обслуживает общий submit → poll конвейер job-пресетов. */
function jobPresetFormats() {
const source = readSource("open-sse/handlers/videoGeneration/job.ts");
const block = source.slice(source.indexOf("VIDEO_JOB_PRESETS"));
return new Set([...block.matchAll(/^\s{2}"([a-z0-9-]+)":\s*\{/gm)].map((match) => match[1]));
}
test("dispatcher formats are still discoverable in the source", () => {
assert.ok(
branchFormats().size >= 10,
"в videoGeneration.ts не нашлось ветвлений по providerConfig.format — " +
"диспетчер переписан, и разбор в этом тесте пора обновить"
);
assert.ok(
jobPresetFormats().size >= 1,
"в videoGeneration/job.ts не нашлось job-пресетов — разбор пора обновить"
);
});
test("every advertised video provider declares a format the dispatcher handles", () => {
const dispatchable = new Set([...branchFormats(), ...jobPresetFormats()]);
const broken = Object.entries(VIDEO_PROVIDERS)
.filter(([, config]) => !config.unsupported)
.filter(([, config]) => !dispatchable.has(config.format))
.map(([providerId, config]) => `${providerId} (format: ${config.format})`);
assert.deepEqual(
broken,
[],
"каталог рекламирует провайдеров, чей формат диспетчер не разбирает — " +
"либо добавьте ветку, либо пометьте провайдера unsupported:\n " +
broken.join("\n ")
);
});
test("unsupported providers state a reason", () => {
const silent = Object.entries(VIDEO_PROVIDERS)
.filter(([, config]) => config.unsupported)
.filter(([, config]) => !config.unsupportedReason?.trim())
.map(([providerId]) => providerId);
assert.deepEqual(silent, [], "провайдер снят с витрины без причины: " + silent.join(", "));
});