mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-14 10:52:17 +03:00
Merge remote-tracking branch 'origin/release/v3.8.51' into chore/12146-batch4-shared-components
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
1
changelog.d/fixes/12073-node26-alias-hooks.md
Normal file
1
changelog.d/fixes/12073-node26-alias-hooks.md
Normal 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)).
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" }],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
107
tests/unit/agentrouter-custom-tool-type.test.ts
Normal file
107
tests/unit/agentrouter-custom-tool-type.test.ts
Normal 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");
|
||||
});
|
||||
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,
|
||||
"короткая очередь не должна приводить к раннему отказу"
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
167
tests/unit/cli/alias-resolver-12073.test.ts
Normal file
167
tests/unit/cli/alias-resolver-12073.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
288
tests/unit/search-432-plan-limit-cooldown.test.ts
Normal file
288
tests/unit/search-432-plan-limit-cooldown.test.ts
Normal 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");
|
||||
});
|
||||
73
tests/unit/video-openai-endpoint-fallback.test.ts
Normal file
73
tests/unit/video-openai-endpoint-fallback.test.ts
Normal 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"]);
|
||||
});
|
||||
86
tests/unit/video-registry-dispatch-parity.test.ts
Normal file
86
tests/unit/video-registry-dispatch-parity.test.ts
Normal 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(", "));
|
||||
});
|
||||
Reference in New Issue
Block a user