mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-31 03:22:19 +03:00
Compare commits
10 Commits
chore/1214
...
chore/1214
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f18ad405cd | ||
|
|
761ab1b3f0 | ||
|
|
7f49b342b5 | ||
|
|
9101b86f5c | ||
|
|
897c3f8c9d | ||
|
|
4e4522c285 | ||
|
|
9aa7c2459a | ||
|
|
8a1d9bf910 | ||
|
|
ececf91e9e | ||
|
|
43f2b2c288 |
@@ -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)).
|
||||
@@ -2595,9 +2595,6 @@
|
||||
"src/shared/components/KiroAuthModal.tsx": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/shared/components/LanguageSelector.tsx": {
|
||||
@@ -2605,11 +2602,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/shared/components/ModelSelectModal.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"src/shared/components/NotificationToast.tsx": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
@@ -2618,28 +2610,11 @@
|
||||
"src/shared/components/OAuthModal.tsx": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 3
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 4
|
||||
}
|
||||
},
|
||||
"src/shared/components/PricingModal.tsx": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/immutability": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/shared/components/ProxyConfigModal.tsx": {
|
||||
"react-hooks/exhaustive-deps": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/immutability": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/shared/components/ProxyLogDetail.tsx": {
|
||||
@@ -2647,16 +2622,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/shared/components/ReasoningRoutingRules.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/shared/components/RequestLoggerDetail.sections.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/shared/components/RequestLoggerV2.tsx": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 3
|
||||
@@ -2676,17 +2641,6 @@
|
||||
},
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 2
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"src/shared/components/UsageStats.tsx": {
|
||||
"react-hooks/preserve-manual-memoization": {
|
||||
"count": 1
|
||||
},
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/shared/components/analytics/charts.tsx": {
|
||||
@@ -2699,16 +2653,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/shared/components/analytics/useProviderDailyUsage.ts": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/shared/components/compression/ComboCompressionModeSelect.tsx": {
|
||||
"react-hooks/set-state-in-effect": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/shared/components/docs/CodeBlock.tsx": {
|
||||
"@typescript-eslint/no-unused-vars": {
|
||||
"count": 1
|
||||
@@ -3552,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,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -37,16 +37,21 @@ export default function KiroAuthModal({
|
||||
const [importingApiKey, setImportingApiKey] = useState(false);
|
||||
const [autoDetecting, setAutoDetecting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) return;
|
||||
setSelectedMethod(null);
|
||||
setIdcStartUrl("");
|
||||
setIdcRegion("us-east-1");
|
||||
setRefreshToken("");
|
||||
setApiKey("");
|
||||
setApiKeyRegion("us-east-1");
|
||||
setError(null);
|
||||
}, [isOpen]);
|
||||
// Reset the form when the modal closes (render-time adjustment per react.dev
|
||||
// "You Might Not Need an Effect" — replaces the old reset effect).
|
||||
const [prevIsOpen, setPrevIsOpen] = useState(isOpen);
|
||||
if (isOpen !== prevIsOpen) {
|
||||
setPrevIsOpen(isOpen);
|
||||
if (!isOpen) {
|
||||
setSelectedMethod(null);
|
||||
setIdcStartUrl("");
|
||||
setIdcRegion("us-east-1");
|
||||
setRefreshToken("");
|
||||
setApiKey("");
|
||||
setApiKeyRegion("us-east-1");
|
||||
setError(null);
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-detect token when import method is selected
|
||||
useEffect(() => {
|
||||
|
||||
@@ -148,64 +148,68 @@ export default function ModelSelectModal({
|
||||
const [testProgress, setTestProgress] = useState<{ done: number; total: number } | null>(null);
|
||||
const [modelTestStatus, setModelTestStatus] = useState<Record<string, "ok" | "error">>({});
|
||||
|
||||
const fetchCombos = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/combos");
|
||||
if (!res.ok) throw new Error(`Failed to fetch combos: ${res.status}`);
|
||||
const data = await res.json();
|
||||
setCombos(data.combos || []);
|
||||
} catch (error) {
|
||||
console.error("Error fetching combos:", error);
|
||||
setCombos([]);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) fetchCombos();
|
||||
if (!isOpen) return;
|
||||
const fetchCombos = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/combos");
|
||||
if (!res.ok) throw new Error(`Failed to fetch combos: ${res.status}`);
|
||||
const data = await res.json();
|
||||
setCombos(data.combos || []);
|
||||
} catch (error) {
|
||||
console.error("Error fetching combos:", error);
|
||||
setCombos([]);
|
||||
}
|
||||
};
|
||||
fetchCombos();
|
||||
}, [isOpen]);
|
||||
|
||||
// Reset provider-test bookkeeping whenever the modal closes so the next
|
||||
// open starts from a clean selection / progress state.
|
||||
// open starts from a clean selection / progress state (render-time
|
||||
// adjustment per react.dev "You Might Not Need an Effect").
|
||||
const [prevIsOpen, setPrevIsOpen] = useState(isOpen);
|
||||
if (isOpen !== prevIsOpen) {
|
||||
setPrevIsOpen(isOpen);
|
||||
if (!isOpen) {
|
||||
setSelectedProviderIds(new Set());
|
||||
setTestingProviders(false);
|
||||
setTestProgress(null);
|
||||
setModelTestStatus({});
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) return;
|
||||
setSelectedProviderIds(new Set());
|
||||
setTestingProviders(false);
|
||||
setTestProgress(null);
|
||||
setModelTestStatus({});
|
||||
if (!isOpen) return;
|
||||
const fetchProviderNodes = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/provider-nodes");
|
||||
if (!res.ok) throw new Error(`Failed to fetch provider nodes: ${res.status}`);
|
||||
const data = await res.json();
|
||||
setProviderNodes(data.nodes || []);
|
||||
} catch (error) {
|
||||
console.error("Error fetching provider nodes:", error);
|
||||
setProviderNodes([]);
|
||||
}
|
||||
};
|
||||
fetchProviderNodes();
|
||||
}, [isOpen]);
|
||||
|
||||
const fetchProviderNodes = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/provider-nodes");
|
||||
if (!res.ok) throw new Error(`Failed to fetch provider nodes: ${res.status}`);
|
||||
const data = await res.json();
|
||||
setProviderNodes(data.nodes || []);
|
||||
} catch (error) {
|
||||
console.error("Error fetching provider nodes:", error);
|
||||
setProviderNodes([]);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) fetchProviderNodes();
|
||||
}, [isOpen]);
|
||||
|
||||
const fetchCustomModels = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/provider-models");
|
||||
if (!res.ok) throw new Error(`Failed to fetch custom models: ${res.status}`);
|
||||
const data = await res.json();
|
||||
setCustomModels(data.models || {});
|
||||
// #9203: keep the unified hidden-model map in sync with the model list.
|
||||
setHiddenModelsByProvider(parseHiddenModelsByProvider(data.hiddenModelsByProvider));
|
||||
} catch (error) {
|
||||
console.error("Error fetching custom models:", error);
|
||||
setCustomModels({});
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) fetchCustomModels();
|
||||
if (!isOpen) return;
|
||||
const fetchCustomModels = async () => {
|
||||
try {
|
||||
const res = await fetch("/api/provider-models");
|
||||
if (!res.ok) throw new Error(`Failed to fetch custom models: ${res.status}`);
|
||||
const data = await res.json();
|
||||
setCustomModels(data.models || {});
|
||||
// #9203: keep the unified hidden-model map in sync with the model list.
|
||||
setHiddenModelsByProvider(parseHiddenModelsByProvider(data.hiddenModelsByProvider));
|
||||
} catch (error) {
|
||||
console.error("Error fetching custom models:", error);
|
||||
setCustomModels({});
|
||||
}
|
||||
};
|
||||
fetchCustomModels();
|
||||
}, [isOpen]);
|
||||
|
||||
// Fetch the live model catalog for one custom provider from its connection's
|
||||
|
||||
@@ -144,7 +144,9 @@ export default function OAuthModal({
|
||||
const [gheUrl, setGheUrl] = useState("");
|
||||
const [polling, setPolling] = useState(false);
|
||||
const [deviceCodeExpiresAt, setDeviceCodeExpiresAt] = useState<number | null>(null);
|
||||
const [deviceCodeSecondsRemaining, setDeviceCodeSecondsRemaining] = useState<number | null>(null);
|
||||
// Wall-clock tick driving the device-code countdown; ticked by the interval
|
||||
// effect below and re-anchored whenever a device flow (re)starts.
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
// API-key paste mode for direct-token providers.
|
||||
const [showPasteToken, setShowPasteToken] = useState(IMPORT_TOKEN_ONLY_PROVIDERS.has(provider));
|
||||
const [pasteToken, setPasteToken] = useState("");
|
||||
@@ -204,7 +206,6 @@ export default function OAuthModal({
|
||||
deviceFlowRunRef.current += 1;
|
||||
setPolling(false);
|
||||
setDeviceCodeExpiresAt(null);
|
||||
setDeviceCodeSecondsRemaining(null);
|
||||
}, []);
|
||||
|
||||
// Define all useCallback hooks BEFORE the useEffects that reference them
|
||||
@@ -323,6 +324,7 @@ export default function OAuthModal({
|
||||
|
||||
setPolling(true);
|
||||
setDeviceCodeExpiresAt(deadline);
|
||||
setNow(Date.now());
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, currentInterval * 1000));
|
||||
@@ -633,33 +635,50 @@ export default function OAuthModal({
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!deviceCodeExpiresAt) {
|
||||
setDeviceCodeSecondsRemaining(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const updateRemaining = () => {
|
||||
setDeviceCodeSecondsRemaining(
|
||||
Math.max(0, Math.ceil((deviceCodeExpiresAt - Date.now()) / 1000))
|
||||
);
|
||||
};
|
||||
updateRemaining();
|
||||
const timer = window.setInterval(updateRemaining, 1000);
|
||||
if (!deviceCodeExpiresAt) return;
|
||||
const timer = window.setInterval(() => setNow(Date.now()), 1000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [deviceCodeExpiresAt]);
|
||||
|
||||
useEffect(() => {
|
||||
invalidateDeviceFlow();
|
||||
flowStartedRef.current = false;
|
||||
// Derived countdown (replaces the old mirrored deviceCodeSecondsRemaining
|
||||
// state): `now` is re-anchored when the device flow starts and ticked by the
|
||||
// interval effect above.
|
||||
const deviceCodeSecondsRemaining =
|
||||
deviceCodeExpiresAt == null ? null : Math.max(0, Math.ceil((deviceCodeExpiresAt - now) / 1000));
|
||||
|
||||
// When the provider changes, reset the flow state during render (react.dev
|
||||
// "You Might Not Need an Effect") and invalidate any in-flight device flow
|
||||
// in a ref-only effect (refs must not be written during render).
|
||||
const [prevProvider, setPrevProvider] = useState(provider);
|
||||
if (provider !== prevProvider) {
|
||||
setPrevProvider(provider);
|
||||
setPolling(false);
|
||||
setDeviceCodeExpiresAt(null);
|
||||
setGrokBrowserMode(false);
|
||||
}, [provider, invalidateDeviceFlow]);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
deviceFlowRunRef.current += 1;
|
||||
flowStartedRef.current = false;
|
||||
}, [provider]);
|
||||
|
||||
// Same split when the modal closes: state reset during render, ref
|
||||
// invalidation in a ref-only effect.
|
||||
const [prevIsOpen, setPrevIsOpen] = useState(isOpen);
|
||||
if (isOpen !== prevIsOpen) {
|
||||
setPrevIsOpen(isOpen);
|
||||
if (!isOpen) {
|
||||
setPolling(false);
|
||||
setDeviceCodeExpiresAt(null);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
invalidateDeviceFlow();
|
||||
deviceFlowRunRef.current += 1;
|
||||
flowStartedRef.current = false;
|
||||
}
|
||||
}, [isOpen, invalidateDeviceFlow]);
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
@@ -668,26 +687,43 @@ export default function OAuthModal({
|
||||
[]
|
||||
);
|
||||
|
||||
// Reset state and start OAuth when modal opens
|
||||
// Reset state and start OAuth when modal opens. The synchronous state resets
|
||||
// moved from the old effect into this render-time adjustment (react.dev
|
||||
// "You Might Not Need an Effect"); the flow itself starts in the effect below.
|
||||
const [prevStartKey, setPrevStartKey] = useState<string | null>(null);
|
||||
const startKey = isOpen && provider ? String(provider) : null;
|
||||
if (startKey !== prevStartKey) {
|
||||
setPrevStartKey(startKey);
|
||||
if (startKey) {
|
||||
setShowPasteToken(IMPORT_TOKEN_ONLY_PROVIDERS.has(provider));
|
||||
setGrokBrowserMode(false);
|
||||
setAuthData(null);
|
||||
setCallbackUrl("");
|
||||
setError(null);
|
||||
setIsDeviceCode(false);
|
||||
setDeviceData(null);
|
||||
setPolling(false);
|
||||
// #8688: show GitLab Duo OAuth app / env setup before authorize error.
|
||||
if (provider === "gitlab-duo") {
|
||||
setStep("gitlab-duo-setup");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !provider || flowStartedRef.current) return;
|
||||
flowStartedRef.current = true;
|
||||
const startsInPasteMode = IMPORT_TOKEN_ONLY_PROVIDERS.has(provider);
|
||||
// #8688: show GitLab Duo OAuth app / env setup before authorize error.
|
||||
const startsInGitlabDuoSetup = provider === "gitlab-duo";
|
||||
setShowPasteToken(startsInPasteMode);
|
||||
setGrokBrowserMode(false);
|
||||
setAuthData(null);
|
||||
setCallbackUrl("");
|
||||
setError(null);
|
||||
setIsDeviceCode(false);
|
||||
setDeviceData(null);
|
||||
setPolling(false);
|
||||
if (startsInGitlabDuoSetup) {
|
||||
setStep("gitlab-duo-setup");
|
||||
// Auto-start is skipped — setStep("gitlab-duo-setup") already happened
|
||||
// in the render-time adjustment above (#8688).
|
||||
return;
|
||||
}
|
||||
if (!startsInPasteMode) startOAuthFlow();
|
||||
flowStartedRef.current = true;
|
||||
const run = async () => {
|
||||
if (!startsInPasteMode) startOAuthFlow();
|
||||
};
|
||||
run();
|
||||
}, [isOpen, provider, startOAuthFlow]);
|
||||
|
||||
// Listen for OAuth callback via multiple methods
|
||||
|
||||
@@ -11,31 +11,29 @@ export default function PricingModal({ isOpen, onClose, onSave }) {
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadPricing();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const loadPricing = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch("/api/pricing");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setPricingData(data);
|
||||
} else {
|
||||
// Fallback to defaults
|
||||
if (!isOpen) return;
|
||||
const loadPricing = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch("/api/pricing");
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setPricingData(data);
|
||||
} else {
|
||||
// Fallback to defaults
|
||||
const defaults = getDefaultPricing();
|
||||
setPricingData(defaults);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load pricing:", error);
|
||||
const defaults = getDefaultPricing();
|
||||
setPricingData(defaults);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load pricing:", error);
|
||||
const defaults = getDefaultPricing();
|
||||
setPricingData(defaults);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
};
|
||||
loadPricing();
|
||||
}, [isOpen]);
|
||||
|
||||
const handlePricingChange = (provider, model, field, value) => {
|
||||
const numValue = parseFloat(value);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { useState, useEffect, useCallback, useMemo } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import Modal from "./Modal";
|
||||
import Button from "./Button";
|
||||
@@ -23,7 +23,9 @@ const BUILD_TIME_SOCKS5 = !["false", "0", "no", "off"].includes(
|
||||
(process.env.NEXT_PUBLIC_ENABLE_SOCKS5_PROXY ?? "").trim().toLowerCase()
|
||||
);
|
||||
export function buildProxyTypes(socks5Enabled: boolean) {
|
||||
return socks5Enabled ? ALL_PROXY_TYPES : ALL_PROXY_TYPES.filter((type) => type.value !== "socks5");
|
||||
return socks5Enabled
|
||||
? ALL_PROXY_TYPES
|
||||
: ALL_PROXY_TYPES.filter((type) => type.value !== "socks5");
|
||||
}
|
||||
|
||||
type ProxyConfigLevel = "global" | "provider" | "combo" | "key";
|
||||
@@ -152,12 +154,40 @@ export default function ProxyConfigModal({
|
||||
return "8080";
|
||||
};
|
||||
|
||||
// Reset transient state when the modal opens (render-time adjustment per
|
||||
// react.dev "You Might Not Need an Effect" — replaces the synchronous
|
||||
// setStates the load effect used to issue).
|
||||
const [prevIsOpen, setPrevIsOpen] = useState(isOpen);
|
||||
if (isOpen !== prevIsOpen) {
|
||||
setPrevIsOpen(isOpen);
|
||||
if (isOpen) {
|
||||
setTestResult(null);
|
||||
setFormError(null);
|
||||
setLoading(true);
|
||||
}
|
||||
}
|
||||
|
||||
const resetFields = useCallback(() => {
|
||||
// ALL_PROXY_TYPES[0] is "http" whether or not SOCKS5 is enabled, so this
|
||||
// reset has no reactive dependencies and stays referentially stable.
|
||||
setProxyType(ALL_PROXY_TYPES[0].value);
|
||||
setHost("");
|
||||
setPort("");
|
||||
setUsername("");
|
||||
setPassword("");
|
||||
setShowAuth(false);
|
||||
setFormError(null);
|
||||
}, []);
|
||||
|
||||
// Translated strings the load effect needs, hoisted so the effect can depend
|
||||
// on stable string values instead of the `t` function identity (an unstable
|
||||
// `t` — e.g. the test mock — would otherwise re-run the load loop forever).
|
||||
const socks5HiddenError = t("errorSocks5Hidden");
|
||||
const levelGlobalLabel = t("levelGlobal");
|
||||
|
||||
// Load existing proxy config when modal opens
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
setTestResult(null);
|
||||
setFormError(null);
|
||||
setLoading(true);
|
||||
|
||||
const loadProxy = async () => {
|
||||
try {
|
||||
@@ -196,7 +226,9 @@ export default function ProxyConfigModal({
|
||||
const assignedProxy = registryItems.find((item) => item.id === target.proxyId);
|
||||
if (assignedProxy?.source === DASHBOARD_CUSTOM_PROXY_SOURCE) {
|
||||
const normalizedType = String(assignedProxy.type || "http").toLowerCase();
|
||||
const hasTypeOption = runtimeProxyTypes.some((entry) => entry.value === normalizedType);
|
||||
const hasTypeOption = runtimeProxyTypes.some(
|
||||
(entry) => entry.value === normalizedType
|
||||
);
|
||||
setMode("custom");
|
||||
setProxyType(hasTypeOption ? normalizedType : runtimeProxyTypes[0]?.value || "http");
|
||||
setHost(assignedProxy.host || "");
|
||||
@@ -209,7 +241,7 @@ export default function ProxyConfigModal({
|
||||
);
|
||||
setShowAuth(!!(assignedProxy.username || assignedProxy.password));
|
||||
if (normalizedType === "socks5" && !runtimeSocks5) {
|
||||
setFormError(t("errorSocks5Hidden"));
|
||||
setFormError(socks5HiddenError);
|
||||
}
|
||||
} else {
|
||||
setMode("saved");
|
||||
@@ -242,7 +274,7 @@ export default function ProxyConfigModal({
|
||||
setShowAuth(!!(proxy.username || proxy.password));
|
||||
setHasOwnProxy(true);
|
||||
if (normalizedType === "socks5" && !runtimeSocks5) {
|
||||
setFormError(t("errorSocks5Hidden"));
|
||||
setFormError(socks5HiddenError);
|
||||
}
|
||||
if (!hasSavedAssignment) setMode("custom");
|
||||
} else {
|
||||
@@ -263,14 +295,14 @@ export default function ProxyConfigModal({
|
||||
if (level === "key") {
|
||||
// Check combo, provider, global
|
||||
if (config.global)
|
||||
setInheritedFrom({ level: t("levelGlobal"), proxy: config.global });
|
||||
setInheritedFrom({ level: levelGlobalLabel, proxy: config.global });
|
||||
// Provider info requires more context, showing global as fallback
|
||||
} else if (level === "combo") {
|
||||
if (config.global)
|
||||
setInheritedFrom({ level: t("levelGlobal"), proxy: config.global });
|
||||
setInheritedFrom({ level: levelGlobalLabel, proxy: config.global });
|
||||
} else if (level === "provider") {
|
||||
if (config.global)
|
||||
setInheritedFrom({ level: t("levelGlobal"), proxy: config.global });
|
||||
setInheritedFrom({ level: levelGlobalLabel, proxy: config.global });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -282,17 +314,7 @@ export default function ProxyConfigModal({
|
||||
};
|
||||
|
||||
loadProxy();
|
||||
}, [isOpen, level, levelId]);
|
||||
|
||||
const resetFields = () => {
|
||||
setProxyType(proxyTypes[0]?.value || "http");
|
||||
setHost("");
|
||||
setPort("");
|
||||
setUsername("");
|
||||
setPassword("");
|
||||
setShowAuth(false);
|
||||
setFormError(null);
|
||||
};
|
||||
}, [isOpen, level, levelId, resetFields, socks5HiddenError, levelGlobalLabel]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (mode === "saved" && !selectedProxyId) {
|
||||
|
||||
@@ -132,7 +132,14 @@ export default function ReasoningRoutingRules({ apiKeyId }: { apiKeyId?: string
|
||||
}, [t]);
|
||||
|
||||
useEffect(() => {
|
||||
load().catch(() => setMessage(t("loadError")));
|
||||
const run = async () => {
|
||||
try {
|
||||
await load();
|
||||
} catch {
|
||||
setMessage(t("loadError"));
|
||||
}
|
||||
};
|
||||
run();
|
||||
}, [load, t]);
|
||||
|
||||
const visibleRules = useMemo(() => {
|
||||
|
||||
@@ -155,9 +155,13 @@ export function ConversationContextSection({ log, detail }) {
|
||||
});
|
||||
const turnsBoxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Adjust state when the `detail` prop changes (render-time adjustment per
|
||||
// react.dev "You Might Not Need an Effect" — replaces the old mirror effect).
|
||||
const [prevDetail, setPrevDetail] = useState(detail);
|
||||
if (detail !== prevDetail) {
|
||||
setPrevDetail(detail);
|
||||
setLiveDetail(detail);
|
||||
}, [detail]);
|
||||
}
|
||||
|
||||
// Same live-poll pattern as the SSE Events section (StreamSection below),
|
||||
// but gated on liveRefresh too: an active request keeps generating either
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback, type CSSProperties } from "react";
|
||||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
useRef,
|
||||
useCallback,
|
||||
useSyncExternalStore,
|
||||
type CSSProperties,
|
||||
} from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { cn } from "@/shared/utils/cn";
|
||||
@@ -59,11 +66,10 @@ type SidebarProps = {
|
||||
|
||||
type HoveredItem = { id: string; label: string; x: number; y: number } | null;
|
||||
|
||||
function loadFromStorage<T>(key: string, fallback: T): T {
|
||||
function parseStoredArray<T>(raw: string | null, fallback: T): T {
|
||||
try {
|
||||
const stored = localStorage.getItem(key);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw);
|
||||
if (Array.isArray(parsed)) return parsed as T;
|
||||
}
|
||||
} catch {}
|
||||
@@ -76,6 +82,29 @@ function saveToStorage(key: string, value: unknown) {
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// useSyncExternalStore plumbing for the one-shot localStorage hydration reads:
|
||||
// nothing to subscribe to (the values are only read once, before
|
||||
// sidebarExpansionLoaded flips), and the server snapshot is always null so the
|
||||
// SSR/hydration render matches the server output.
|
||||
const noopSubscribe = () => () => {};
|
||||
const getServerSnapshotNull = () => null;
|
||||
const getHydratedSnapshot = () => true;
|
||||
const getServerHydratedSnapshot = () => false;
|
||||
function readStoredExpandedRaw() {
|
||||
try {
|
||||
return localStorage.getItem(EXPANDED_SECTIONS_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function readStoredPinnedRaw() {
|
||||
try {
|
||||
return localStorage.getItem(PINNED_SECTIONS_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function Sidebar({
|
||||
onClose,
|
||||
collapsed = false,
|
||||
@@ -115,35 +144,47 @@ export default function Sidebar({
|
||||
);
|
||||
const [pinnedSections, setPinnedSections] = useState<Set<SidebarSectionId>>(new Set());
|
||||
const [sidebarExpansionLoaded, setSidebarExpansionLoaded] = useState(false);
|
||||
const skipInitialActiveExpansion = useRef(false);
|
||||
const [skipInitialActiveExpansion, setSkipInitialActiveExpansion] = useState(false);
|
||||
const [hoveredItem, setHoveredItem] = useState<HoveredItem>(null);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
|
||||
// Load persisted state on mount. A stored [] intentionally means "all sections collapsed".
|
||||
useEffect(() => {
|
||||
const storedExpanded = loadFromStorage<SidebarSectionId[]>(EXPANDED_SECTIONS_KEY, [
|
||||
// Load persisted state once the client has hydrated. A stored [] intentionally
|
||||
// means "all sections collapsed". localStorage is read through
|
||||
// useSyncExternalStore snapshots (server snapshot: null) and the states are
|
||||
// adjusted during render (react.dev "You Might Not Need an Effect") so the
|
||||
// stored expansion applies before paint without a synchronous effect setState.
|
||||
const hydrated = useSyncExternalStore(
|
||||
noopSubscribe,
|
||||
getHydratedSnapshot,
|
||||
getServerHydratedSnapshot
|
||||
);
|
||||
const storedExpandedRaw = useSyncExternalStore(
|
||||
noopSubscribe,
|
||||
readStoredExpandedRaw,
|
||||
getServerSnapshotNull
|
||||
);
|
||||
const storedPinnedRaw = useSyncExternalStore(
|
||||
noopSubscribe,
|
||||
readStoredPinnedRaw,
|
||||
getServerSnapshotNull
|
||||
);
|
||||
if (hydrated && !sidebarExpansionLoaded) {
|
||||
const storedExpanded = parseStoredArray<SidebarSectionId[]>(storedExpandedRaw, [
|
||||
DEFAULT_EXPANDED,
|
||||
]);
|
||||
const pinnedRaw = (() => {
|
||||
try {
|
||||
return localStorage.getItem(PINNED_SECTIONS_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
const storedPinned: SidebarSectionId[] =
|
||||
pinnedRaw !== null
|
||||
? (JSON.parse(pinnedRaw) as SidebarSectionId[])
|
||||
storedPinnedRaw !== null
|
||||
? parseStoredArray<SidebarSectionId[]>(storedPinnedRaw, [])
|
||||
: (SIDEBAR_SECTIONS.filter((s) => s.defaultPinned).map((s) => s.id) as SidebarSectionId[]);
|
||||
|
||||
const initialPinned = new Set<SidebarSectionId>(storedPinned);
|
||||
const initialExpanded = hydrateExpandedSections(storedExpanded, initialPinned);
|
||||
|
||||
skipInitialActiveExpansion.current = storedExpanded.length === 0;
|
||||
setSkipInitialActiveExpansion(storedExpanded.length === 0);
|
||||
setExpandedSections(initialExpanded);
|
||||
setPinnedSections(initialPinned);
|
||||
setSidebarExpansionLoaded(true);
|
||||
}, []);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const applySettings = (data) => {
|
||||
@@ -292,38 +333,51 @@ export default function Sidebar({
|
||||
? filterSidebarSectionsByQuery(visibleSections, searchQuery)
|
||||
: visibleSections;
|
||||
|
||||
// Keep the active page visible while preserving accordion semantics for unpinned sections.
|
||||
useEffect(() => {
|
||||
if (collapsed || !sidebarExpansionLoaded) return;
|
||||
if (skipInitialActiveExpansion.current) {
|
||||
skipInitialActiveExpansion.current = false;
|
||||
return;
|
||||
}
|
||||
for (const section of visibleSections) {
|
||||
const sectionItems = section.children.flatMap((child: any) =>
|
||||
child.type === "group" ? child.items : [child]
|
||||
);
|
||||
if (sectionItems.some((item: any) => !item.external && item.href === activeHref)) {
|
||||
setExpandedSections((prev) => {
|
||||
const next = expandActiveSection(pinnedSections, section.id as SidebarSectionId);
|
||||
if ([...next].every((id) => prev.has(id)) && next.size === prev.size) return prev;
|
||||
saveToStorage(EXPANDED_SECTIONS_KEY, [...next]);
|
||||
return next;
|
||||
});
|
||||
break;
|
||||
// Keep the active page visible while preserving accordion semantics for
|
||||
// unpinned sections. Render-time adjustment (react.dev "You Might Not Need
|
||||
// an Effect"): the composite key mirrors the old effect's
|
||||
// [activeHref, collapsed, pinnedSections, sidebarExpansionLoaded] deps.
|
||||
const activeExpansionKey = `${collapsed}|${sidebarExpansionLoaded}|${activeHref ?? ""}|${[
|
||||
...pinnedSections,
|
||||
]
|
||||
.sort()
|
||||
.join(",")}`;
|
||||
const [prevActiveExpansionKey, setPrevActiveExpansionKey] = useState<string | null>(null);
|
||||
if (activeExpansionKey !== prevActiveExpansionKey) {
|
||||
setPrevActiveExpansionKey(activeExpansionKey);
|
||||
if (!collapsed && sidebarExpansionLoaded) {
|
||||
if (skipInitialActiveExpansion) {
|
||||
setSkipInitialActiveExpansion(false);
|
||||
} else {
|
||||
for (const section of visibleSections) {
|
||||
const sectionItems = section.children.flatMap((child: any) =>
|
||||
child.type === "group" ? child.items : [child]
|
||||
);
|
||||
if (sectionItems.some((item: any) => !item.external && item.href === activeHref)) {
|
||||
setExpandedSections((prev) => {
|
||||
const next = expandActiveSection(pinnedSections, section.id as SidebarSectionId);
|
||||
if ([...next].every((id) => prev.has(id)) && next.size === prev.size) return prev;
|
||||
return next;
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [activeHref, collapsed, pinnedSections, sidebarExpansionLoaded]);
|
||||
}
|
||||
|
||||
// Persist the expanded-section set whenever it changes after hydration —
|
||||
// single writer replacing the saveToStorage calls that used to run inside
|
||||
// setState updaters (side effects belong outside updaters).
|
||||
useEffect(() => {
|
||||
if (!sidebarExpansionLoaded) return;
|
||||
saveToStorage(EXPANDED_SECTIONS_KEY, [...expandedSections]);
|
||||
}, [expandedSections, sidebarExpansionLoaded]);
|
||||
|
||||
// Accordion toggle: opening a section closes all non-pinned sections
|
||||
const toggleSection = useCallback(
|
||||
(sectionId: SidebarSectionId) => {
|
||||
setExpandedSections((prev) => {
|
||||
const next = toggleExpandedSection(prev, pinnedSections, sectionId);
|
||||
saveToStorage(EXPANDED_SECTIONS_KEY, [...next]);
|
||||
return next;
|
||||
});
|
||||
setExpandedSections((prev) => toggleExpandedSection(prev, pinnedSections, sectionId));
|
||||
},
|
||||
[pinnedSections]
|
||||
);
|
||||
@@ -340,7 +394,6 @@ export default function Sidebar({
|
||||
if (prevExp.has(sectionId)) return prevExp;
|
||||
const nextExp = new Set(prevExp);
|
||||
nextExp.add(sectionId);
|
||||
saveToStorage(EXPANDED_SECTIONS_KEY, [...nextExp]);
|
||||
return nextExp;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -113,13 +113,15 @@ export default function UsageStats() {
|
||||
() => sortData(stats?.byModel, stats?.pending?.byModel),
|
||||
[stats?.byModel, stats?.pending?.byModel, sortData]
|
||||
);
|
||||
const statsByAccount = stats?.byAccount;
|
||||
const statsPendingByAccount = stats?.pending?.byAccount;
|
||||
const sortedAccounts = useMemo(() => {
|
||||
// For accounts, pendingMap is by connectionId, but dataMap is by accountKey
|
||||
// We need to map connectionId pending counts to accountKeys
|
||||
const accountPendingMap: Record<string, any> = {};
|
||||
if (stats?.pending?.byAccount) {
|
||||
Object.entries(stats.byAccount || {}).forEach(([accountKey, data]: [string, any]) => {
|
||||
const connPending = stats.pending.byAccount[data.connectionId];
|
||||
if (statsPendingByAccount) {
|
||||
Object.entries(statsByAccount || {}).forEach(([accountKey, data]: [string, any]) => {
|
||||
const connPending = statsPendingByAccount[data.connectionId];
|
||||
if (connPending) {
|
||||
// Get modelKey (rawModel (provider))
|
||||
const modelKey = data.provider ? `${data.rawModel} (${data.provider})` : data.rawModel;
|
||||
@@ -127,11 +129,12 @@ export default function UsageStats() {
|
||||
}
|
||||
});
|
||||
}
|
||||
return sortData(stats?.byAccount, accountPendingMap);
|
||||
}, [stats?.byAccount, stats?.pending?.byAccount, sortData]);
|
||||
return sortData(statsByAccount, accountPendingMap);
|
||||
}, [statsByAccount, statsPendingByAccount, sortData]);
|
||||
|
||||
// Note: no synchronous setLoading(true) here — `loading` starts as true and the
|
||||
// only showLoading=true call happens on mount, so the skeleton is already up.
|
||||
const fetchStats = useCallback(async (showLoading = true): Promise<void> => {
|
||||
if (showLoading) setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/usage/history");
|
||||
if (res.ok) {
|
||||
@@ -157,7 +160,10 @@ export default function UsageStats() {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchStats();
|
||||
const run = async () => {
|
||||
await fetchStats();
|
||||
};
|
||||
run();
|
||||
}, [fetchStats]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* max-lines-per-function complexity gate.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { readFetchErrorMessage } from "@/shared/utils/fetchError";
|
||||
import type { ProviderDailyUsageRow } from "./RequestCountTable";
|
||||
@@ -17,30 +17,34 @@ export function useProviderDailyUsage(range: string, dateFilter: string) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchRows = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const params = new URLSearchParams();
|
||||
if (dateFilter) {
|
||||
params.set("date", dateFilter);
|
||||
} else {
|
||||
params.set("range", range);
|
||||
}
|
||||
const res = await fetch(`/api/usage/requests-by-provider-date?${params.toString()}`);
|
||||
if (!res.ok) throw new Error(await readFetchErrorMessage(res, tCommon("error")));
|
||||
const data = await res.json();
|
||||
setRows(Array.isArray(data.rows) ? data.rows : []);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [range, dateFilter, tCommon]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const fetchRows = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (dateFilter) {
|
||||
params.set("date", dateFilter);
|
||||
} else {
|
||||
params.set("range", range);
|
||||
}
|
||||
const res = await fetch(`/api/usage/requests-by-provider-date?${params.toString()}`);
|
||||
if (!res.ok) throw new Error(await readFetchErrorMessage(res, tCommon("error")));
|
||||
const data = await res.json();
|
||||
if (cancelled) return;
|
||||
setRows(Array.isArray(data.rows) ? data.rows : []);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
if (!cancelled) setError((err as Error).message);
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
};
|
||||
fetchRows();
|
||||
}, [fetchRows]);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [range, dateFilter, tCommon]);
|
||||
|
||||
return { rows, loading, error };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
export interface ComboCompressionModeSelectCombo {
|
||||
@@ -53,9 +53,15 @@ export function ComboCompressionModeSelect({
|
||||
const [compressionOverride, setCompressionOverride] = useState(initialCompressionMode);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Re-sync the select when the combo's persisted mode changes (render-time
|
||||
// adjustment per react.dev "You Might Not Need an Effect" — replaces the old
|
||||
// mirror effect that called setState synchronously).
|
||||
const [prevInitialCompressionMode, setPrevInitialCompressionMode] =
|
||||
useState(initialCompressionMode);
|
||||
if (initialCompressionMode !== prevInitialCompressionMode) {
|
||||
setPrevInitialCompressionMode(initialCompressionMode);
|
||||
setCompressionOverride(initialCompressionMode);
|
||||
}, [initialCompressionMode]);
|
||||
}
|
||||
|
||||
const handleChange = async (value: string) => {
|
||||
setCompressionOverride(value);
|
||||
|
||||
@@ -265,6 +265,7 @@
|
||||
"tests/unit/kimi-quota-reset-recovery.test.ts",
|
||||
"tests/unit/least-used-rotation-10945.test.ts",
|
||||
"tests/unit/lkgp-stale-pin-exhaustion-11911.test.ts",
|
||||
"tests/unit/search-432-plan-limit-cooldown.test.ts",
|
||||
"tests/unit/livews-forward-backoff-4604.test.ts",
|
||||
"tests/unit/management-auth-hardening.test.ts",
|
||||
"tests/unit/mark-account-unavailable-numeric-epoch-guard.test.ts",
|
||||
|
||||
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